diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..aef16d2e
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,18 @@
+# pandoc format parameters taken from https://github.com/TokTok/spec/blob/54ee53de924210295e311d10c63527c47468305c/pandoc.mk
+FORMAT := markdown_github
+FORMAT := $(FORMAT)-hard_line_breaks
+FORMAT := $(FORMAT)-native_spans
+FORMAT := $(FORMAT)-raw_html
+FORMAT := $(FORMAT)+tex_math_dollars
+FORMAT := $(FORMAT)-tex_math_single_backslash
+
+PANDOC_ARGS := \
+ --no-tex-ligatures \
+ --atx-headers \
+ --columns=79 \
+
+all:
+ printf "nope"
+
+format:
+ pandoc $(PANDOC_ARGS) -f $(FORMAT) -t $(FORMAT) doc/db.md -o doc/db.md
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
index d5cea5bd..69275baa 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -39,6 +39,5 @@ dependencies {
compile 'com.android.support:support-v4:25.0.0'
compile 'com.android.support:recyclerview-v7:25.0.0'
compile 'com.yarolegovich:lovely-dialog:1.0.4'
- compile 'net.zetetic:android-database-sqlcipher:3.5.3@aar'
testCompile 'junit:junit:4.12'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 15250fb9..a104e2c8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -4,6 +4,8 @@
+
+
another.Order){
return -1;
diff --git a/app/src/main/java/me/impy/aegis/KeyProfileAdapter.java b/app/src/main/java/me/impy/aegis/KeyProfileAdapter.java
index b136fc8e..b790fecf 100644
--- a/app/src/main/java/me/impy/aegis/KeyProfileAdapter.java
+++ b/app/src/main/java/me/impy/aegis/KeyProfileAdapter.java
@@ -1,11 +1,9 @@
package me.impy.aegis;
import android.animation.ObjectAnimator;
-import android.content.ClipData;
import android.content.SharedPreferences;
import android.os.Handler;
import android.preference.PreferenceManager;
-import android.provider.ContactsContract;
import android.support.v7.widget.RecyclerView;
import android.view.LayoutInflater;
import android.view.View;
@@ -24,8 +22,6 @@ import java.util.Comparator;
import java.util.List;
import me.impy.aegis.crypto.OTP;
-import me.impy.aegis.db.Database;
-import me.impy.aegis.helpers.ItemClickListener;
import me.impy.aegis.helpers.ItemTouchHelperAdapter;
public class KeyProfileAdapter extends RecyclerView.Adapter implements ItemTouchHelperAdapter {
diff --git a/app/src/main/java/me/impy/aegis/MainActivity.java b/app/src/main/java/me/impy/aegis/MainActivity.java
index b2b5bbd0..c8b6e8f0 100644
--- a/app/src/main/java/me/impy/aegis/MainActivity.java
+++ b/app/src/main/java/me/impy/aegis/MainActivity.java
@@ -1,10 +1,8 @@
package me.impy.aegis;
-import android.app.FragmentManager;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
-import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ShortcutInfo;
@@ -12,6 +10,7 @@ import android.content.pm.ShortcutManager;
import android.graphics.drawable.Icon;
import android.net.Uri;
import android.os.Build;
+import android.hardware.fingerprint.FingerprintManager;
import android.preference.PreferenceManager;
import android.support.design.widget.BottomSheetDialog;
import android.support.design.widget.FloatingActionButton;
@@ -26,31 +25,43 @@ import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
-import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.Toast;
+import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Objects;
+import javax.crypto.Cipher;
+import javax.crypto.IllegalBlockSizeException;
+import javax.crypto.SecretKey;
+
+import me.impy.aegis.crypto.CryptParameters;
+import me.impy.aegis.crypto.CryptResult;
import me.impy.aegis.crypto.CryptoUtils;
+import me.impy.aegis.crypto.DerivationParameters;
+import me.impy.aegis.crypto.KeyStoreHandle;
import me.impy.aegis.crypto.OTP;
import me.impy.aegis.db.Database;
+import me.impy.aegis.db.DatabaseFile;
+import me.impy.aegis.finger.FingerprintAuthenticationDialogFragment;
import me.impy.aegis.helpers.SimpleItemTouchHelperCallback;
-public class MainActivity extends AppCompatActivity {
+public class MainActivity extends AppCompatActivity {
static final int GET_KEYINFO = 1;
static final int ADD_KEYINFO = 2;
RecyclerView rvKeyProfiles;
KeyProfileAdapter mKeyProfileAdapter;
- ArrayList mKeyProfiles;
+ ArrayList mKeyProfiles = new ArrayList<>();
Database database;
+ DatabaseFile databaseFile;
+ boolean blockSave = false;
boolean nightMode = false;
int clickedItemPosition = -1;
@@ -59,19 +70,16 @@ public class MainActivity extends AppCompatActivity {
super.onCreate(savedInstanceState);
SharedPreferences prefs = this.getSharedPreferences("me.impy.aegis", Context.MODE_PRIVATE);
- if(!prefs.getBoolean("passedIntro", false))
- {
+ if (!prefs.getBoolean("passedIntro", false)) {
Intent intro = new Intent(this, IntroActivity.class);
startActivity(intro);
}
SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
- if(sharedPreferences.getBoolean("pref_night_mode", false))
- {
+ if (sharedPreferences.getBoolean("pref_night_mode", false)) {
nightMode = true;
setTheme(R.style.AppTheme_Dark_NoActionBar);
- } else
- {
+ } else {
setPreferredTheme();
}
@@ -80,43 +88,24 @@ public class MainActivity extends AppCompatActivity {
setSupportActionBar(toolbar);
initializeAppShortcuts();
FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
+ fab.setEnabled(false);
fab.setOnClickListener(view -> {
+ blockSave = true;
Intent scannerActivity = new Intent(getApplicationContext(), ScannerActivity.class);
startActivityForResult(scannerActivity, GET_KEYINFO);
});
- char[] password = "test".toCharArray();
- database = Database.createInstance(getApplicationContext(), "keys.db", password);
- CryptoUtils.zero(password);
-
- mKeyProfiles = new ArrayList<>();
-
rvKeyProfiles = (RecyclerView) findViewById(R.id.rvKeyProfiles);
LinearLayoutManager mLayoutManager = new LinearLayoutManager(this);
rvKeyProfiles.setLayoutManager(mLayoutManager);
- final Context context = this.getApplicationContext();
- //EditProfileBottomSheetdialog bottomSheetDialog = EditProfileBottomSheetdialog.getInstance();
-
mKeyProfileAdapter = new KeyProfileAdapter(mKeyProfiles);
mKeyProfileAdapter.setOnItemClickListener((position, v) -> {
clickedItemPosition = position;
InitializeBottomSheet().show();
});
-
- //View dialogView = bottomSheetDialog.getView();
- //LinearLayout copyLayout = (LinearLayout)dialogView.findViewById(R.id.copy_button);
- /*copyLayout.setOnClickListener(view -> {
- ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
- ClipData clip = ClipData.newPlainText("text/plain", mKeyProfiles.get(clickedItemPosition).Code);
- clipboard.setPrimaryClip(clip);
-
- Toast.makeText(context, "Code successfully copied to the clipboard", Toast.LENGTH_SHORT).show();
- });*/
-
mKeyProfileAdapter.setOnLongItemClickListener((position, v) -> {
-
});
ItemTouchHelper.Callback callback = new SimpleItemTouchHelperCallback(mKeyProfileAdapter);
@@ -131,17 +120,13 @@ public class MainActivity extends AppCompatActivity {
}
};
Collections.sort(mKeyProfiles, comparator);
-
- try {
- mKeyProfiles.addAll(database.getKeys());
- mKeyProfileAdapter.notifyDataSetChanged();
- } catch (Exception e) {
- e.printStackTrace();
- }
+
+ loadDatabase(null);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ blockSave = false;
if (requestCode == GET_KEYINFO) {
if (resultCode == RESULT_OK) {
final KeyProfile keyProfile = (KeyProfile)data.getSerializableExtra("KeyProfile");
@@ -153,7 +138,7 @@ public class MainActivity extends AppCompatActivity {
}
else if (requestCode == ADD_KEYINFO) {
if (resultCode == RESULT_OK) {
- final KeyProfile keyProfile = (KeyProfile)data.getSerializableExtra("KeyProfile");
+ final KeyProfile keyProfile = (KeyProfile) data.getSerializableExtra("KeyProfile");
String otp;
try {
@@ -165,14 +150,17 @@ public class MainActivity extends AppCompatActivity {
keyProfile.Order = mKeyProfiles.size() + 1;
keyProfile.Code = otp;
- mKeyProfiles.add(keyProfile);
- mKeyProfileAdapter.notifyDataSetChanged();
-
try {
database.addKey(keyProfile);
} catch (Exception e) {
e.printStackTrace();
+ // TODO: feedback
+ return;
}
+
+ mKeyProfiles.add(keyProfile);
+ mKeyProfileAdapter.notifyDataSetChanged();
+ saveDatabase(true, null);
}
}
}
@@ -186,18 +174,84 @@ public class MainActivity extends AppCompatActivity {
@Override
protected void onPause() {
- for(int i = 0; i < mKeyProfiles.size(); i++)
- {
- try {
- database.updateKey(mKeyProfiles.get(i));
- } catch (Exception e) {
- e.printStackTrace();
+ if (!blockSave) {
+ // update order of keys
+ for (int i = 0; i < mKeyProfiles.size(); i++) {
+ try {
+ database.updateKey(mKeyProfiles.get(i));
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
}
+
+ saveDatabase(false, null);
}
super.onPause();
}
+ private void promptFingerPrint(FingerprintAuthenticationDialogFragment.Action action, Cipher cipher) {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
+ FingerprintAuthenticationDialogFragment fragment = new FingerprintAuthenticationDialogFragment();
+ fragment.setCryptoObject(new FingerprintManager.CryptoObject(cipher));
+ fragment.setStage(FingerprintAuthenticationDialogFragment.Stage.FINGERPRINT);
+ fragment.setAction(action);
+ fragment.show(getFragmentManager(), "");
+ }
+ }
+
+ public void onAuthenticated(FingerprintAuthenticationDialogFragment.Action action, FingerprintManager.CryptoObject obj) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ Cipher cipher = obj.getCipher();
+ switch (action) {
+ case SAVE:
+ saveDatabase(false, cipher);
+ break;
+ case LOAD:
+ loadDatabase(cipher);
+ break;
+ }
+ }
+ }
+
+ private void saveDatabase(boolean allowPrompt, Cipher cipher) {
+ try {
+ byte[] bytes = database.serialize();
+ CryptParameters cryptParams = null;
+ DerivationParameters derParams = null;
+
+ switch (databaseFile.getLevel()) {
+ case DatabaseFile.SEC_LEVEL_DERIVED:
+ // TODO
+ break;
+ case DatabaseFile.SEC_LEVEL_KEYSTORE:
+ if (cipher == null) {
+ KeyStoreHandle keyStore = new KeyStoreHandle();
+ SecretKey key = keyStore.getKey();
+ cipher = CryptoUtils.createCipher(key, Cipher.ENCRYPT_MODE);
+ }
+
+ CryptResult result = CryptoUtils.encrypt(bytes, cipher);
+ bytes = result.Data;
+ cryptParams = result.Parameters;
+ break;
+ }
+
+ databaseFile.setContent(bytes);
+ databaseFile.setCryptParameters(cryptParams);
+ databaseFile.setDerivationParameters(derParams);
+ databaseFile.save();
+ } catch (IllegalBlockSizeException e) {
+ // TODO: is there a way to catch "Key user not authenticated" specifically aside from checking the exception message?
+ if (causeIsKeyUserNotAuthenticated(e) && allowPrompt && cipher != null) {
+ promptFingerPrint(FingerprintAuthenticationDialogFragment.Action.SAVE, cipher);
+ }
+ e.printStackTrace();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
private BottomSheetDialog InitializeBottomSheet()
{
View bottomSheetView = getLayoutInflater ().inflate (R.layout.bottom_sheet_edit_profile, null);
@@ -242,7 +296,13 @@ public class MainActivity extends AppCompatActivity {
.setTitle("Delete entry")
.setMessage("Are you sure you want to delete this profile?")
.setPositiveButton(android.R.string.yes, (dialog, which) -> {
- database.removeKey(profile);
+ try {
+ database.removeKey(profile);
+ } catch (Exception e) {
+ e.printStackTrace();
+ //TODO: feedback
+ return;
+ }
mKeyProfiles.remove(clickedItemPosition);
mKeyProfileAdapter.notifyItemRemoved(clickedItemPosition);
})
@@ -335,4 +395,108 @@ public class MainActivity extends AppCompatActivity {
startActivity(new Intent(this, this.getClass()));
}
}
+
+ private void loadDatabase(Cipher cipher) {
+ try {
+ databaseFile = DatabaseFile.load(getApplicationContext());
+ } catch (IOException e) {
+ // this file doesn't exist yet
+ try {
+ // TODO: prompt for security settings (level, auth, etc)
+ database = new Database();
+ databaseFile = new DatabaseFile(getApplicationContext());
+ databaseFile.setLevel(DatabaseFile.SEC_LEVEL_KEYSTORE);
+
+ if (databaseFile.getLevel() == DatabaseFile.SEC_LEVEL_KEYSTORE) {
+ KeyStoreHandle store = new KeyStoreHandle();
+ if (!store.keyExists()) {
+ store.generateKey(true);
+ }
+ }
+ } catch (Exception ex) {
+ e.printStackTrace();
+ return;
+ }
+ } catch (Exception e) {
+ // something else went wrong
+ e.printStackTrace();
+ return;
+ }
+
+ if (database == null) {
+ byte[] content = databaseFile.getContent();
+ switch (databaseFile.getLevel()) {
+ case DatabaseFile.SEC_LEVEL_NONE:
+ try {
+ Database temp = new Database();
+ temp.deserialize(content);
+ database = temp;
+ } catch (Exception e) {
+ // TODO: handle corrupt database
+ e.printStackTrace();
+ return;
+ }
+ break;
+ case DatabaseFile.SEC_LEVEL_DERIVED:
+ // TODO: prompt for pin/pass
+ /*CryptParameters cryptParams = dbFile.getCryptParameters();
+ DerivationParameters derParams = dbFile.getDerivationParameters();
+ SecretKey key = CryptoUtils.deriveKey("password".toCharArray(), derParams.Salt, (int)derParams.IterationCount);*/
+
+ break;
+ case DatabaseFile.SEC_LEVEL_KEYSTORE:
+ // TODO: prompt for fingerprint if auth is required
+ try {
+ CryptParameters params = databaseFile.getCryptParameters();
+
+ if (cipher == null) {
+ KeyStoreHandle store = new KeyStoreHandle();
+ SecretKey key = store.getKey();
+ cipher = CryptoUtils.createCipher(key, Cipher.DECRYPT_MODE, params.Nonce);
+ }
+
+ CryptResult result = null;
+ //try {
+ result = CryptoUtils.decrypt(content, cipher, params);
+ //} catch (Exception e) {
+ // // we probably need to authenticate ourselves
+ // promptFingerPrint(1, cipher);
+ //}
+ if (result != null) {
+ database = new Database();
+ database.deserialize(result.Data);
+ }
+ } catch (IllegalBlockSizeException e) {
+ if (causeIsKeyUserNotAuthenticated(e) && cipher != null) {
+ promptFingerPrint(FingerprintAuthenticationDialogFragment.Action.LOAD, cipher);
+ }
+ e.printStackTrace();
+ return;
+ } catch (Exception e) {
+ e.printStackTrace();
+ return;
+ }
+ break;
+ default:
+ // TODO: handle unknown security level
+ return;
+ }
+ }
+
+ try {
+ mKeyProfiles.addAll(database.getKeys());
+ mKeyProfileAdapter.notifyDataSetChanged();
+ } catch (Exception e) {
+ e.printStackTrace();
+ return;
+ }
+
+ FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
+ fab.setEnabled(true);
+ }
+
+ private boolean causeIsKeyUserNotAuthenticated(Exception e) {
+ // TODO: is there a way to catch "Key user not authenticated" specifically aside from checking the exception message?
+ return e.getCause().getMessage().equals("Key user not authenticated");
+ }
}
diff --git a/app/src/main/java/me/impy/aegis/crypto/CryptParameters.java b/app/src/main/java/me/impy/aegis/crypto/CryptParameters.java
new file mode 100644
index 00000000..4c3140da
--- /dev/null
+++ b/app/src/main/java/me/impy/aegis/crypto/CryptParameters.java
@@ -0,0 +1,6 @@
+package me.impy.aegis.crypto;
+
+public class CryptParameters {
+ public byte[] Nonce;
+ public byte[] Tag;
+}
\ No newline at end of file
diff --git a/app/src/main/java/me/impy/aegis/crypto/CryptResult.java b/app/src/main/java/me/impy/aegis/crypto/CryptResult.java
new file mode 100644
index 00000000..661c2c3e
--- /dev/null
+++ b/app/src/main/java/me/impy/aegis/crypto/CryptResult.java
@@ -0,0 +1,6 @@
+package me.impy.aegis.crypto;
+
+public class CryptResult {
+ public CryptParameters Parameters;
+ public byte[] Data;
+}
diff --git a/app/src/main/java/me/impy/aegis/crypto/CryptoUtils.java b/app/src/main/java/me/impy/aegis/crypto/CryptoUtils.java
index 6d798253..3719d6dc 100644
--- a/app/src/main/java/me/impy/aegis/crypto/CryptoUtils.java
+++ b/app/src/main/java/me/impy/aegis/crypto/CryptoUtils.java
@@ -1,9 +1,93 @@
package me.impy.aegis.crypto;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.KeySpec;
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.SecretKeyFactory;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.PBEKeySpec;
+
public class CryptoUtils {
- private CryptoUtils() {
+ public static final byte CRYPTO_TAG_SIZE = 16;
+ public static final byte CRYPTO_KEY_SIZE = 32;
+ public static final byte CRYPTO_NONCE_SIZE = 12;
+ public static final byte CRYPTO_SALT_SIZE = 32;
+ public static final String CRYPTO_CIPHER = "AES/GCM/NoPadding";
+ public static final String CRYPTO_DERIVE_ALGO = "PBKDF2WithHmacSHA256";
+
+ public static SecretKey deriveKey(char[] password, byte[] salt, int iterations) throws NoSuchAlgorithmException, InvalidKeySpecException {
+ SecretKeyFactory factory = SecretKeyFactory.getInstance(CRYPTO_DERIVE_ALGO);
+ KeySpec spec = new PBEKeySpec(password, salt, iterations, CRYPTO_KEY_SIZE * 8);
+ return factory.generateSecret(spec);
+ }
+
+ public static Cipher createCipher(SecretKey key, int opmode) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException {
+ byte[] nonce = generateNonce();
+ return createCipher(key, opmode, nonce);
+ }
+
+ public static Cipher createCipher(SecretKey key, int opmode, byte[] nonce) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException {
+ GCMParameterSpec spec = new GCMParameterSpec(CRYPTO_TAG_SIZE * 8, nonce);
+ Cipher cipher = Cipher.getInstance(CRYPTO_CIPHER);
+ cipher.init(opmode, key, spec);
+ return cipher;
+ }
+
+ public static CryptResult encrypt(byte[] data, Cipher cipher) throws BadPaddingException, IllegalBlockSizeException {
+ // split off the tag to store it separately
+ byte[] result = cipher.doFinal(data);
+ byte[] tag = Arrays.copyOfRange(result, result.length - CRYPTO_TAG_SIZE, result.length);
+ byte[] encrypted = Arrays.copyOfRange(result, 0, result.length - CRYPTO_TAG_SIZE);
+
+ return new CryptResult() {{
+ Parameters = new CryptParameters() {{
+ Nonce = cipher.getIV();
+ Tag = tag;
+ }};
+ Data = encrypted;
+ }};
+ }
+
+ public static CryptResult decrypt(byte[] encrypted, Cipher cipher, CryptParameters params) throws IOException, BadPaddingException, IllegalBlockSizeException {
+ // append the tag to the ciphertext
+ ByteArrayOutputStream stream = new ByteArrayOutputStream();
+ stream.write(encrypted);
+ stream.write(params.Tag);
+
+ encrypted = stream.toByteArray();
+ byte[] decrypted = cipher.doFinal(encrypted);
+
+ return new CryptResult() {{
+ Parameters = params;
+ Data = decrypted;
+ }};
+ }
+
+ public static byte[] generateSalt() {
+ return generateRandomBytes(CRYPTO_KEY_SIZE);
+ }
+
+ public static byte[] generateNonce() {
+ return generateRandomBytes(CRYPTO_NONCE_SIZE);
+ }
+
+ private static byte[] generateRandomBytes(int length) {
+ SecureRandom random = new SecureRandom();
+ byte[] data = new byte[length];
+ random.nextBytes(data);
+ return data;
}
public static void zero(char[] data) {
@@ -11,6 +95,6 @@ public class CryptoUtils {
}
public static void zero(byte[] data) {
- Arrays.fill(data, (byte)0);
+ Arrays.fill(data, (byte) 0);
}
}
diff --git a/app/src/main/java/me/impy/aegis/crypto/DerivationParameters.java b/app/src/main/java/me/impy/aegis/crypto/DerivationParameters.java
new file mode 100644
index 00000000..e81111db
--- /dev/null
+++ b/app/src/main/java/me/impy/aegis/crypto/DerivationParameters.java
@@ -0,0 +1,6 @@
+package me.impy.aegis.crypto;
+
+public class DerivationParameters {
+ public long IterationCount;
+ public byte[] Salt;
+}
diff --git a/app/src/main/java/me/impy/aegis/crypto/KeyStoreHandle.java b/app/src/main/java/me/impy/aegis/crypto/KeyStoreHandle.java
new file mode 100644
index 00000000..3374fca0
--- /dev/null
+++ b/app/src/main/java/me/impy/aegis/crypto/KeyStoreHandle.java
@@ -0,0 +1,53 @@
+package me.impy.aegis.crypto;
+
+import android.os.Build;
+import android.security.keystore.KeyGenParameterSpec;
+import android.security.keystore.KeyProperties;
+
+import java.io.IOException;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.UnrecoverableKeyException;
+import java.security.cert.CertificateException;
+
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+
+public class KeyStoreHandle {
+ private final KeyStore keyStore;
+ private static final String KEY_NAME = "AegisKey";
+ private static final String STORE_NAME = "AndroidKeyStore";
+
+ public KeyStoreHandle() throws KeyStoreException, CertificateException, NoSuchAlgorithmException, IOException {
+ keyStore = KeyStore.getInstance(STORE_NAME);
+ keyStore.load(null);
+ }
+
+ public boolean keyExists() throws KeyStoreException {
+ return keyStore.containsAlias(KEY_NAME);
+ }
+
+ public SecretKey generateKey(boolean authRequired) throws Exception {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, STORE_NAME);
+ generator.init(new KeyGenParameterSpec.Builder(KEY_NAME,
+ KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setUserAuthenticationRequired(authRequired)
+ .setRandomizedEncryptionRequired(false)
+ .setKeySize(CryptoUtils.CRYPTO_KEY_SIZE * 8)
+ //.setUserAuthenticationValidityDurationSeconds(30);
+ .build());
+
+ return generator.generateKey();
+ } else {
+ throw new Exception("Symmetric KeyStore keys are not supported in this version of Android");
+ }
+ }
+
+ public SecretKey getKey() throws UnrecoverableKeyException, NoSuchAlgorithmException, KeyStoreException {
+ return (SecretKey) keyStore.getKey(KEY_NAME, null);
+ }
+}
diff --git a/app/src/main/java/me/impy/aegis/db/Database.java b/app/src/main/java/me/impy/aegis/db/Database.java
index d9746e92..c6e9bc65 100644
--- a/app/src/main/java/me/impy/aegis/db/Database.java
+++ b/app/src/main/java/me/impy/aegis/db/Database.java
@@ -1,99 +1,94 @@
package me.impy.aegis.db;
-import android.content.Context;
-
-import net.sqlcipher.Cursor;
-import net.sqlcipher.database.SQLiteDatabase;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
import java.util.List;
import me.impy.aegis.KeyProfile;
import me.impy.aegis.crypto.KeyInfo;
public class Database {
- private static Database instance;
- private static Boolean libsLoaded = false;
- private SQLiteDatabase db;
+ private static final int version = 1;
+ private List entries = new ArrayList<>();
- private Database(Context context, String filename, char[] password) {
- DatabaseHelper helper = new DatabaseHelper(context, filename);
- db = helper.getWritableDatabase(password);
- }
-
- public static Database createInstance(Context context, String filename, char[] password) {
- // load the sqlcipher library, once
- if (!libsLoaded) {
- SQLiteDatabase.loadLibs(context);
- libsLoaded = true;
+ public byte[] serialize() throws JSONException, UnsupportedEncodingException {
+ JSONArray array = new JSONArray();
+ for (DatabaseEntry e : entries) {
+ array.put(e.serialize());
}
- if (instance == null) {
- instance = new Database(context, filename, password);
- }
+ JSONObject obj = new JSONObject();
+ obj.put("version", version);
+ obj.put("entries", array);
- return instance;
+ return obj.toString().getBytes("UTF-8");
+ }
+
+ public void deserialize(byte[] data) throws Exception {
+ JSONObject obj = new JSONObject(new String(data, "UTF-8"));
+
+ // TODO: support different version deserialization providers
+ int ver = obj.getInt("version");
+ if (ver != version) {
+ throw new Exception("Unsupported version");
+ }
+
+ JSONArray array = obj.getJSONArray("entries");
+ for (int i = 0; i < array.length(); i++) {
+ DatabaseEntry e = new DatabaseEntry();
+ e.deserialize(array.getJSONObject(i));
+ entries.add(e);
+ }
}
- // adds a key to the database and returns it's ID
public void addKey(KeyProfile profile) throws Exception {
- db.execSQL("insert into otp (name, url) values (?, ?)",
- new Object[]{ profile.Name, profile.Info.getURL() });
- profile.ID = getLastID(db, "otp");
+ DatabaseEntry e = new DatabaseEntry();
+ e.Name = profile.Name;
+ e.URL = profile.Info.getURL();
+ e.Order = profile.Order;
+ e.ID = entries.size() + 1;
+ profile.ID = e.ID;
+ entries.add(e);
}
public void updateKey(KeyProfile profile) throws Exception {
- db.execSQL("update otp set name=?, url=?, 'order'=? where id=?",
- new Object[]{ profile.Name, profile.Info.getURL(), profile.Order, profile.ID });
+ DatabaseEntry e = findEntry(profile);
+ e.Name = profile.Name;
+ e.URL = profile.Info.getURL();
+ e.Order = profile.Order;
}
- public void removeKey(KeyProfile profile) {
- db.execSQL("delete from otp where id=?", new Object[]{ profile.ID });
+ public void removeKey(KeyProfile profile) throws Exception {
+ DatabaseEntry e = findEntry(profile);
+ entries.remove(e);
}
public List getKeys() throws Exception {
List list = new ArrayList<>();
- Cursor cursor = db.rawQuery("select * from otp order by 'order' desc", null);
- try {
- while (cursor.moveToNext()) {
- KeyProfile profile = new KeyProfile();
- profile.ID = cursor.getInt(cursor.getColumnIndexOrThrow("id"));
- profile.Name = cursor.getString(cursor.getColumnIndexOrThrow("name"));
- profile.Order = cursor.getInt(cursor.getColumnIndexOrThrow("order"));
- String url = cursor.getString(cursor.getColumnIndexOrThrow("url"));
- profile.Info = KeyInfo.FromURL(url);
-
- list.add(profile);
- }
- Collections.sort(list, new Comparator() {
- @Override
- public int compare(KeyProfile a, KeyProfile b) {
- return b.compareTo(a);
- }
- });
- return list;
- } finally {
- cursor.close();
+ for (DatabaseEntry e : entries) {
+ KeyProfile profile = new KeyProfile();
+ profile.Name = e.Name;
+ profile.Info = KeyInfo.FromURL(e.URL);
+ profile.Order = e.Order;
+ profile.ID = e.ID;
+ list.add(profile);
}
+
+ return list;
}
- public void close() {
- db.close();
- }
-
- private int getLastID(SQLiteDatabase db, String table) throws Exception {
- Cursor cursor = db.rawQuery(String.format("select id from %s order by id desc limit 1", table), null);
- try {
- if (!cursor.moveToFirst()) {
- throw new Exception("no items in the table, this should not happen here");
+ private DatabaseEntry findEntry(KeyProfile profile) throws Exception {
+ for (DatabaseEntry e : entries) {
+ if (e.ID == profile.ID) {
+ return e;
}
-
- return cursor.getInt(cursor.getColumnIndexOrThrow("id"));
- } finally {
- cursor.close();
}
+
+ throw new Exception("Key doesn't exist");
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/me/impy/aegis/db/DatabaseEntry.java b/app/src/main/java/me/impy/aegis/db/DatabaseEntry.java
new file mode 100644
index 00000000..19d1616d
--- /dev/null
+++ b/app/src/main/java/me/impy/aegis/db/DatabaseEntry.java
@@ -0,0 +1,27 @@
+package me.impy.aegis.db;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public class DatabaseEntry {
+ public int ID;
+ public String Name;
+ public String URL;
+ public int Order;
+
+ public JSONObject serialize() throws JSONException {
+ JSONObject obj = new JSONObject();
+ obj.put("id", ID);
+ obj.put("name", Name);
+ obj.put("url", URL);
+ obj.put("order", Order);
+ return obj;
+ }
+
+ public void deserialize(JSONObject obj) throws JSONException {
+ ID = obj.getInt("id");
+ Name = obj.getString("name");
+ URL = obj.getString("url");
+ Order = obj.getInt("order");
+ }
+}
diff --git a/app/src/main/java/me/impy/aegis/db/DatabaseFile.java b/app/src/main/java/me/impy/aegis/db/DatabaseFile.java
new file mode 100644
index 00000000..fb9d722d
--- /dev/null
+++ b/app/src/main/java/me/impy/aegis/db/DatabaseFile.java
@@ -0,0 +1,247 @@
+package me.impy.aegis.db;
+
+import android.content.Context;
+
+import java.io.ByteArrayOutputStream;
+import java.io.DataOutputStream;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.cert.CertificateException;
+import java.util.Arrays;
+
+import me.impy.aegis.crypto.CryptParameters;
+import me.impy.aegis.crypto.CryptoUtils;
+import me.impy.aegis.crypto.DerivationParameters;
+
+public class DatabaseFile {
+ public static final byte SEC_LEVEL_NONE = 0x00;
+ public static final byte SEC_LEVEL_DERIVED = 0x01;
+ public static final byte SEC_LEVEL_KEYSTORE = 0x02;
+ private static final byte bSectionEncryptionParameters = 0x00;
+ private static final byte bSectionDerivationParameters = 0x01;
+ private static final byte bSectionEnd = (byte) 0xFF;
+ private static final byte bVersion = 1;
+ private static final String dbFilename = "aegis.db";
+
+ private final byte[] bHeader;
+ private final Context context;
+
+ private byte level;
+ private byte[] content;
+ private CryptParameters cryptParameters;
+ private DerivationParameters derivationParameters;
+
+ public DatabaseFile(Context ctx) throws IOException, KeyStoreException, CertificateException, NoSuchAlgorithmException {
+ context = ctx;
+ bHeader = "AEGIS".getBytes("US_ASCII");
+ }
+
+ public byte[] serialize() throws IOException {
+ CryptParameters cryptParams = getCryptParameters();
+ DerivationParameters derParams = getDerivationParameters();
+ byte[] content = getContent();
+ byte level = getLevel();
+
+ // this is dumb, java doesn't provide an endianness-aware data stream
+ ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
+ DataOutputStream stream = new DataOutputStream(byteStream);
+ stream.write(bHeader);
+ stream.write(bVersion);
+ stream.write(level);
+
+ // we assume that all of the needed params for the security level are set
+ // if that's not the case, a NullPointerException will be thrown.
+ switch (level) {
+ case SEC_LEVEL_DERIVED:
+ ByteBuffer paramBuffer = newBuffer(/* iterations */ 8 + CryptoUtils.CRYPTO_SALT_SIZE);
+ paramBuffer.putLong(derParams.IterationCount);
+ paramBuffer.put(derParams.Salt);
+ writeSection(stream, bSectionDerivationParameters, paramBuffer.array());
+ // intentional fallthrough
+ case SEC_LEVEL_KEYSTORE:
+ paramBuffer = newBuffer(CryptoUtils.CRYPTO_NONCE_SIZE + CryptoUtils.CRYPTO_TAG_SIZE);
+ paramBuffer.put(cryptParams.Nonce);
+ paramBuffer.put(cryptParams.Tag);
+ writeSection(stream, bSectionEncryptionParameters, paramBuffer.array());
+ break;
+ }
+
+ writeSection(stream, bSectionEnd, null);
+ stream.write(content);
+ return byteStream.toByteArray();
+ }
+
+ public void deserialize(byte[] data) throws Exception {
+ ByteBuffer buffer = newBuffer(data);
+
+ byte[] header = new byte[bHeader.length];
+ buffer.get(header);
+ if (!Arrays.equals(header, bHeader)) {
+ throw new Exception("Bad header");
+ }
+
+ // TODO: support different version deserialization providers
+ byte version = buffer.get();
+ if (version != bVersion) {
+ throw new Exception("Unsupported version");
+ }
+
+ byte level = buffer.get();
+ if (level > SEC_LEVEL_KEYSTORE) {
+ throw new Exception("Unsupported security level");
+ }
+ setLevel(level);
+
+ CryptParameters cryptParams = null;
+ DerivationParameters derParams = null;
+
+ for (section s = readSection(buffer); s.ID != bSectionEnd; s = readSection(buffer)) {
+ ByteBuffer sBuff = newBuffer(s.Data);
+ switch (s.ID) {
+ case bSectionEncryptionParameters:
+ assertLength(s.Data, CryptoUtils.CRYPTO_NONCE_SIZE + CryptoUtils.CRYPTO_TAG_SIZE);
+
+ byte[] nonce = new byte[CryptoUtils.CRYPTO_NONCE_SIZE];
+ byte[] tag = new byte[CryptoUtils.CRYPTO_TAG_SIZE];
+ sBuff.get(nonce);
+ sBuff.get(tag);
+
+ cryptParams = new CryptParameters() {{
+ Nonce = nonce;
+ Tag = tag;
+ }};
+ break;
+ case bSectionDerivationParameters:
+ assertLength(s.Data, /* iterations */ 8 + CryptoUtils.CRYPTO_SALT_SIZE);
+
+ long iterations = sBuff.getLong();
+ byte[] salt = new byte[CryptoUtils.CRYPTO_SALT_SIZE];
+ sBuff.get(salt);
+
+ derParams = new DerivationParameters() {{
+ IterationCount = iterations;
+ Salt = salt;
+ }};
+ break;
+ }
+ }
+
+ if ((level == SEC_LEVEL_DERIVED && (cryptParams == null || derParams == null))
+ || (level == SEC_LEVEL_KEYSTORE && cryptParams == null)) {
+ throw new Exception("Security level parameters missing");
+ }
+
+ setCryptParameters(cryptParams);
+ setDerivationParameters(derParams);
+
+ byte[] content = new byte[buffer.remaining()];
+ buffer.get(content);
+ setContent(content);
+ }
+
+ public void save() throws IOException {
+ byte[] data = serialize();
+
+ FileOutputStream file = context.openFileOutput(dbFilename, Context.MODE_PRIVATE);
+ file.write(data);
+ file.close();
+ }
+
+ public static DatabaseFile load(Context context) throws Exception {
+ FileInputStream file = context.openFileInput(dbFilename);
+ byte[] data = new byte[(int) file.getChannel().size()];
+ file.read(data);
+ file.close();
+
+ DatabaseFile db = new DatabaseFile(context);
+ db.deserialize(data);
+ return db;
+ }
+
+ private static void writeSection(DataOutputStream stream, byte id, byte[] data) throws IOException {
+ stream.write(id);
+
+ ByteBuffer buffer = newBuffer(/* sizeof uint32_t */ 4);
+ if (data == null) {
+ buffer.putInt(0);
+ } else {
+ buffer.putInt(data.length);
+ }
+ stream.write(buffer.array());
+
+ if (data != null) {
+ stream.write(data);
+ }
+ }
+
+ private static section readSection(ByteBuffer buffer) {
+ section s = new section();
+ s.ID = buffer.get();
+
+ int len = buffer.getInt();
+ s.Data = new byte[len];
+ buffer.get(s.Data);
+
+ return s;
+ }
+
+ private static ByteBuffer newBuffer(byte[] data) {
+ ByteBuffer buffer = ByteBuffer.wrap(data);
+ buffer.order(ByteOrder.LITTLE_ENDIAN);
+ return buffer;
+ }
+
+ private static ByteBuffer newBuffer(int size) {
+ ByteBuffer buffer = ByteBuffer.allocate(size);
+ buffer.order(ByteOrder.LITTLE_ENDIAN);
+ return buffer;
+ }
+
+ private static void assertLength(byte[] bytes, int length) throws Exception {
+ if (bytes.length != length) {
+ throw new Exception("Bad length");
+ }
+ }
+
+ public byte[] getContent() {
+ return content;
+ }
+
+ public void setContent(byte[] content) {
+ this.content = content;
+ }
+
+ public CryptParameters getCryptParameters() {
+ return cryptParameters;
+ }
+
+ public void setCryptParameters(CryptParameters parameters) {
+ this.cryptParameters = parameters;
+ }
+
+ public DerivationParameters getDerivationParameters() {
+ return derivationParameters;
+ }
+
+ public void setDerivationParameters(DerivationParameters derivationParameters) {
+ this.derivationParameters = derivationParameters;
+ }
+
+ public byte getLevel() {
+ return level;
+ }
+
+ public void setLevel(byte level) {
+ this.level = level;
+ }
+
+ private static class section {
+ public byte ID;
+ public byte[] Data;
+ }
+}
diff --git a/app/src/main/java/me/impy/aegis/db/DatabaseHelper.java b/app/src/main/java/me/impy/aegis/db/DatabaseHelper.java
deleted file mode 100644
index 4b999ec2..00000000
--- a/app/src/main/java/me/impy/aegis/db/DatabaseHelper.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package me.impy.aegis.db;
-
-import android.content.Context;
-
-import net.sqlcipher.database.SQLiteDatabase;
-import net.sqlcipher.database.SQLiteOpenHelper;
-
-import java.io.File;
-
-import me.impy.aegis.crypto.KeyInfo;
-import me.impy.aegis.encoding.Base32;
-
-public class DatabaseHelper extends SQLiteOpenHelper {
- // NOTE: increment this every time the schema is changed
- public static final int Version = 1;
-
- private static final String queryCreateOTPTable =
- "create table otp (" +
- "id integer primary key autoincrement, " +
- "name varchar not null, " +
- "url varchar not null, " +
- "'order' integer)";
-
- public DatabaseHelper(Context context, String filename) {
- super(context, filename, null, Version);
- }
-
- public void onCreate(SQLiteDatabase db) {
- db.execSQL(queryCreateOTPTable);
- }
- public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
- //db.execSQL(SQL_DELETE_ENTRIES);
- //onCreate(db);
- }
- public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) {
- onUpgrade(db, oldVersion, newVersion);
- }
-}
diff --git a/app/src/main/java/me/impy/aegis/finger/FingerprintAuthenticationDialogFragment.java b/app/src/main/java/me/impy/aegis/finger/FingerprintAuthenticationDialogFragment.java
new file mode 100644
index 00000000..fca10304
--- /dev/null
+++ b/app/src/main/java/me/impy/aegis/finger/FingerprintAuthenticationDialogFragment.java
@@ -0,0 +1,277 @@
+package me.impy.aegis.finger;
+
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+import android.app.Activity;
+import android.app.DialogFragment;
+import android.content.SharedPreferences;
+import android.hardware.fingerprint.FingerprintManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.preference.PreferenceManager;
+import android.support.annotation.RequiresApi;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import me.impy.aegis.MainActivity;
+import me.impy.aegis.R;
+
+/**
+ * A dialog which uses fingerprint APIs to authenticate the user, and falls back to password
+ * authentication if fingerprint is not available.
+ */
+@RequiresApi(api = Build.VERSION_CODES.M)
+public class FingerprintAuthenticationDialogFragment extends DialogFragment
+ implements TextView.OnEditorActionListener, FingerprintUiHelper.Callback {
+
+ private Button mCancelButton;
+ private Button mSecondDialogButton;
+ private View mFingerprintContent;
+ private View mBackupContent;
+ private EditText mPassword;
+ private CheckBox mUseFingerprintFutureCheckBox;
+ private TextView mPasswordDescriptionTextView;
+ private TextView mNewFingerprintEnrolledTextView;
+
+ private Stage mStage = Stage.FINGERPRINT;
+ private Action mAction;
+
+ private FingerprintManager.CryptoObject mCryptoObject;
+ private FingerprintUiHelper mFingerprintUiHelper;
+ private MainActivity mActivity;
+
+ private InputMethodManager mInputMethodManager;
+ private SharedPreferences mSharedPreferences;
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Do not create a new Fragment when the Activity is re-created such as orientation changes.
+ setRetainInstance(true);
+ setStyle(DialogFragment.STYLE_NORMAL, android.R.style.Theme_Material_Light_Dialog);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container,
+ Bundle savedInstanceState) {
+ getDialog().setTitle(getString(R.string.sign_in));
+ View v = inflater.inflate(R.layout.fingerprint_dialog_container, container, false);
+ mCancelButton = (Button) v.findViewById(R.id.cancel_button);
+ mCancelButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ dismiss();
+ }
+ });
+
+ mSecondDialogButton = (Button) v.findViewById(R.id.second_dialog_button);
+ mSecondDialogButton.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (mStage == Stage.FINGERPRINT) {
+ goToBackup();
+ } else {
+ verifyPassword();
+ }
+ }
+ });
+ mFingerprintContent = v.findViewById(R.id.fingerprint_container);
+ mBackupContent = v.findViewById(R.id.backup_container);
+ mPassword = (EditText) v.findViewById(R.id.password);
+ mPassword.setOnEditorActionListener(this);
+ mPasswordDescriptionTextView = (TextView) v.findViewById(R.id.password_description);
+ mUseFingerprintFutureCheckBox = (CheckBox)
+ v.findViewById(R.id.use_fingerprint_in_future_check);
+ mNewFingerprintEnrolledTextView = (TextView)
+ v.findViewById(R.id.new_fingerprint_enrolled_description);
+ mFingerprintUiHelper = new FingerprintUiHelper(
+ mActivity.getSystemService(FingerprintManager.class),
+ (ImageView) v.findViewById(R.id.fingerprint_icon),
+ (TextView) v.findViewById(R.id.fingerprint_status), this);
+ updateStage();
+
+ // If fingerprint authentication is not available, switch immediately to the backup
+ // (password) screen.
+ if (!mFingerprintUiHelper.isFingerprintAuthAvailable()) {
+ goToBackup();
+ }
+ return v;
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ if (mStage == Stage.FINGERPRINT) {
+ mFingerprintUiHelper.startListening(mCryptoObject);
+ }
+ }
+
+ public void setStage(Stage stage) {
+ mStage = stage;
+ }
+
+ public void setAction(Action action) { mAction = action; }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ mFingerprintUiHelper.stopListening();
+ }
+
+ @Override
+ public void onAttach(Activity activity) {
+ super.onAttach(activity);
+ mActivity = (MainActivity) activity;
+ mInputMethodManager = mActivity.getSystemService(InputMethodManager.class);
+ mSharedPreferences = PreferenceManager.getDefaultSharedPreferences(mActivity);
+ }
+
+ /**
+ * Sets the crypto object to be passed in when authenticating with fingerprint.
+ */
+ public void setCryptoObject(FingerprintManager.CryptoObject cryptoObject) {
+ mCryptoObject = cryptoObject;
+ }
+
+ /**
+ * Switches to backup (password) screen. This either can happen when fingerprint is not
+ * available or the user chooses to use the password authentication method by pressing the
+ * button. This can also happen when the user had too many fingerprint attempts.
+ */
+ private void goToBackup() {
+ mStage = Stage.PASSWORD;
+ updateStage();
+ mPassword.requestFocus();
+
+ // Show the keyboard.
+ mPassword.postDelayed(mShowKeyboardRunnable, 500);
+
+ // Fingerprint is not used anymore. Stop listening for it.
+ mFingerprintUiHelper.stopListening();
+ }
+
+ /**
+ * Checks whether the current entered password is correct, and dismisses the the dialog and
+ * let's the activity know about the result.
+ */
+ private void verifyPassword() {
+ if (!checkPassword(mPassword.getText().toString())) {
+ return;
+ }
+ if (mStage == Stage.NEW_FINGERPRINT_ENROLLED) {
+ SharedPreferences.Editor editor = mSharedPreferences.edit();
+ editor.putBoolean(getString(R.string.use_fingerprint_to_authenticate_key),
+ mUseFingerprintFutureCheckBox.isChecked());
+ editor.apply();
+
+ if (mUseFingerprintFutureCheckBox.isChecked()) {
+ // Re-create the key so that fingerprints including new ones are validated.
+ //mActivity.createKey(MainActivity.DEFAULT_KEY_NAME, true);
+ mStage = Stage.FINGERPRINT;
+ }
+ }
+ mPassword.setText("");
+ //mActivity.onPurchased(, null);
+ dismiss();
+ }
+
+ /**
+ * @return true if {@code password} is correct, false otherwise
+ */
+ private boolean checkPassword(String password) {
+ // Assume the password is always correct.
+ // In the real world situation, the password needs to be verified in the server side.
+ return password.length() > 0;
+ }
+
+ private final Runnable mShowKeyboardRunnable = new Runnable() {
+ @Override
+ public void run() {
+ mInputMethodManager.showSoftInput(mPassword, 0);
+ }
+ };
+
+ private void updateStage() {
+ switch (mStage) {
+ case FINGERPRINT:
+ mCancelButton.setText(R.string.cancel);
+ mSecondDialogButton.setText(R.string.use_password);
+ mFingerprintContent.setVisibility(View.VISIBLE);
+ mBackupContent.setVisibility(View.GONE);
+ break;
+ case NEW_FINGERPRINT_ENROLLED:
+ // Intentional fall through
+ case PASSWORD:
+ mCancelButton.setText(R.string.cancel);
+ mSecondDialogButton.setText(R.string.ok);
+ mFingerprintContent.setVisibility(View.GONE);
+ mBackupContent.setVisibility(View.VISIBLE);
+ if (mStage == Stage.NEW_FINGERPRINT_ENROLLED) {
+ mPasswordDescriptionTextView.setVisibility(View.GONE);
+ mNewFingerprintEnrolledTextView.setVisibility(View.VISIBLE);
+ mUseFingerprintFutureCheckBox.setVisibility(View.VISIBLE);
+ }
+ break;
+ }
+ }
+
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ if (actionId == EditorInfo.IME_ACTION_GO) {
+ verifyPassword();
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void onAuthenticated() {
+ // Callback from FingerprintUiHelper. Let the activity know that authentication was
+ // successful.
+ mActivity.onAuthenticated(mAction, mCryptoObject);
+ dismiss();
+ }
+
+ @Override
+ public void onError() {
+ goToBackup();
+ }
+
+ /**
+ * Enumeration to indicate which authentication method the user is trying to authenticate with.
+ */
+ public enum Stage {
+ FINGERPRINT,
+ NEW_FINGERPRINT_ENROLLED,
+ PASSWORD
+ }
+
+ public enum Action {
+ LOAD,
+ SAVE
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/me/impy/aegis/finger/FingerprintUiHelper.java b/app/src/main/java/me/impy/aegis/finger/FingerprintUiHelper.java
new file mode 100644
index 00000000..e51be546
--- /dev/null
+++ b/app/src/main/java/me/impy/aegis/finger/FingerprintUiHelper.java
@@ -0,0 +1,150 @@
+package me.impy.aegis.finger;
+
+/*
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+import android.hardware.fingerprint.FingerprintManager;
+import android.os.Build;
+import android.os.CancellationSignal;
+import android.support.annotation.RequiresApi;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import me.impy.aegis.R;
+
+/**
+ * Small helper class to manage text/icon around fingerprint authentication UI.
+ */
+@RequiresApi(api = Build.VERSION_CODES.M)
+public class FingerprintUiHelper extends FingerprintManager.AuthenticationCallback {
+
+ private static final long ERROR_TIMEOUT_MILLIS = 1600;
+ private static final long SUCCESS_DELAY_MILLIS = 1300;
+
+ private final FingerprintManager mFingerprintManager;
+ private final ImageView mIcon;
+ private final TextView mErrorTextView;
+ private final Callback mCallback;
+ private CancellationSignal mCancellationSignal;
+
+ private boolean mSelfCancelled;
+
+ /**
+ * Constructor for {@link FingerprintUiHelper}.
+ */
+ FingerprintUiHelper(FingerprintManager fingerprintManager,
+ ImageView icon, TextView errorTextView, Callback callback) {
+ mFingerprintManager = fingerprintManager;
+ mIcon = icon;
+ mErrorTextView = errorTextView;
+ mCallback = callback;
+ }
+
+ public boolean isFingerprintAuthAvailable() {
+ // The line below prevents the false positive inspection from Android Studio
+ // noinspection ResourceType
+ return mFingerprintManager.isHardwareDetected()
+ && mFingerprintManager.hasEnrolledFingerprints();
+ }
+
+ public void startListening(FingerprintManager.CryptoObject cryptoObject) {
+ if (!isFingerprintAuthAvailable()) {
+ return;
+ }
+ mCancellationSignal = new CancellationSignal();
+ mSelfCancelled = false;
+ // The line below prevents the false positive inspection from Android Studio
+ // noinspection ResourceType
+ mFingerprintManager
+ .authenticate(cryptoObject, mCancellationSignal, 0 /* flags */, this, null);
+ mIcon.setImageResource(R.drawable.ic_fp_40px);
+ }
+
+ public void stopListening() {
+ if (mCancellationSignal != null) {
+ mSelfCancelled = true;
+ mCancellationSignal.cancel();
+ mCancellationSignal = null;
+ }
+ }
+
+ @Override
+ public void onAuthenticationError(int errMsgId, CharSequence errString) {
+ if (!mSelfCancelled) {
+ showError(errString);
+ mIcon.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ mCallback.onError();
+ }
+ }, ERROR_TIMEOUT_MILLIS);
+ }
+ }
+
+ @Override
+ public void onAuthenticationHelp(int helpMsgId, CharSequence helpString) {
+ showError(helpString);
+ }
+
+ @Override
+ public void onAuthenticationFailed() {
+ showError(mIcon.getResources().getString(
+ R.string.fingerprint_not_recognized));
+ }
+
+ @Override
+ public void onAuthenticationSucceeded(FingerprintManager.AuthenticationResult result) {
+ mErrorTextView.removeCallbacks(mResetErrorTextRunnable);
+ mIcon.setImageResource(R.drawable.ic_fingerprint_success);
+ mErrorTextView.setTextColor(
+ mErrorTextView.getResources().getColor(R.color.success_color, null));
+ mErrorTextView.setText(
+ mErrorTextView.getResources().getString(R.string.fingerprint_success));
+ mIcon.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ mCallback.onAuthenticated();
+ }
+ }, SUCCESS_DELAY_MILLIS);
+ }
+
+ private void showError(CharSequence error) {
+ mIcon.setImageResource(R.drawable.ic_fingerprint_error);
+ mErrorTextView.setText(error);
+ mErrorTextView.setTextColor(
+ mErrorTextView.getResources().getColor(R.color.warning_color, null));
+ mErrorTextView.removeCallbacks(mResetErrorTextRunnable);
+ mErrorTextView.postDelayed(mResetErrorTextRunnable, ERROR_TIMEOUT_MILLIS);
+ }
+
+ private Runnable mResetErrorTextRunnable = new Runnable() {
+ @Override
+ public void run() {
+ mErrorTextView.setTextColor(
+ mErrorTextView.getResources().getColor(R.color.hint_color, null));
+ mErrorTextView.setText(
+ mErrorTextView.getResources().getString(R.string.fingerprint_hint));
+ mIcon.setImageResource(R.drawable.ic_fp_40px);
+ }
+ };
+
+ public interface Callback {
+
+ void onAuthenticated();
+
+ void onError();
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable-hdpi/ic_fp_40px.png b/app/src/main/res/drawable-hdpi/ic_fp_40px.png
new file mode 100644
index 00000000..48ebd8ad
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_fp_40px.png differ
diff --git a/app/src/main/res/drawable-hdpi/tile.9.png b/app/src/main/res/drawable-hdpi/tile.9.png
new file mode 100644
index 00000000..13586288
Binary files /dev/null and b/app/src/main/res/drawable-hdpi/tile.9.png differ
diff --git a/app/src/main/res/drawable-mdpi/ic_fp_40px.png b/app/src/main/res/drawable-mdpi/ic_fp_40px.png
new file mode 100644
index 00000000..122f4425
Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_fp_40px.png differ
diff --git a/app/src/main/res/drawable-nodpi/android_robot.png b/app/src/main/res/drawable-nodpi/android_robot.png
new file mode 100644
index 00000000..40bf934b
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/android_robot.png differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_fp_40px.png b/app/src/main/res/drawable-xhdpi/ic_fp_40px.png
new file mode 100644
index 00000000..e1c9590b
Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_fp_40px.png differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_fp_40px.png b/app/src/main/res/drawable-xxhdpi/ic_fp_40px.png
new file mode 100644
index 00000000..f7e87240
Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_fp_40px.png differ
diff --git a/app/src/main/res/drawable-xxxhdpi/ic_fp_40px.png b/app/src/main/res/drawable-xxxhdpi/ic_fp_40px.png
new file mode 100644
index 00000000..0fb85452
Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_fp_40px.png differ
diff --git a/app/src/main/res/drawable/ic_fingerprint_error.xml b/app/src/main/res/drawable/ic_fingerprint_error.xml
new file mode 100644
index 00000000..be46116d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fingerprint_error.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_fingerprint_success.xml b/app/src/main/res/drawable/ic_fingerprint_success.xml
new file mode 100644
index 00000000..261f3e7f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_fingerprint_success.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fingerprint_dialog_backup.xml b/app/src/main/res/layout/fingerprint_dialog_backup.xml
new file mode 100644
index 00000000..7c0cebc6
--- /dev/null
+++ b/app/src/main/res/layout/fingerprint_dialog_backup.xml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fingerprint_dialog_container.xml b/app/src/main/res/layout/fingerprint_dialog_container.xml
new file mode 100644
index 00000000..53c8e9af
--- /dev/null
+++ b/app/src/main/res/layout/fingerprint_dialog_container.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fingerprint_dialog_content.xml b/app/src/main/res/layout/fingerprint_dialog_content.xml
new file mode 100644
index 00000000..cd488509
--- /dev/null
+++ b/app/src/main/res/layout/fingerprint_dialog_content.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index efb32246..18b8d4cc 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -19,4 +19,8 @@
#ffffff
#FF5252
#191919
+
+ #f4511e
+ #42000000
+ #009688
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cee8da8d..649771c5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -7,4 +7,28 @@
Enable this to use darker colors
Show the issuer
Enable this to show the issuer next to the profile name
+
+ Cancel
+ Use password
+ Sign in
+ Ok
+ Password
+ Confirm fingerprint to continue
+ Touch sensor
+ Enter your store password to continue
+ Purchase
+ Purchase not invalidated
+
+ You can proceed to purchase with this button \n even if a new fingerprint is enrolled
+
+ Fingerprint not recognized. Try again
+ Fingerprint recognized
+ White Mesh Pluto Backpack
+ $62.68
+ Mesh backpack in white. Black textile trim throughout.
+ Purchase successful
+ A new fingerprint was added to this device, so your password is required.
+ Use fingerprint in the future
+ Use fingerprint to authenticate
+ use_fingerprint_to_authenticate_key
diff --git a/doc/db.md b/doc/db.md
new file mode 100644
index 00000000..0ecea38d
--- /dev/null
+++ b/doc/db.md
@@ -0,0 +1,119 @@
+# Database
+
+The database is encoded in a simple binary format with JSON content at its
+core.
+
+## Encryption
+
+The content of the database can be encrypted with AES in GCM mode. The nonce
+and authentication tag are stored in the plain section of this file. The
+storage place for the key depends on the security level that is used. This will
+be discussed later.
+
+## Format
+
+The file format starts with a small header that contains some magic, the
+version number and the level of security. A list of sections follows. These
+sections contain some information needed to perform decryption of the database.
+The (encrypted) content of the database starts after the end marker section.
+
+All integers are encoded in Little Endian.
+
+### Header
+
+| Length | Contents |
+|:-------|:-------------------------|
+| `5` | "AEGIS" encoded in ASCII |
+| `1` | `uint8_t` Version |
+| `1` | `uint8_t` Level |
+| `?` | List of sections |
+| `?` | Content |
+
+#### Levels
+
+As mentioned above, there are different levels of security that a user can
+choose from. No encryption, encryption using a derived key and encryption using
+a key that's stored in the Android KeyStore.
+
+| Value | Name |
+|:-------|:---------|
+| `0x00` | None |
+| `0x01` | Derived |
+| `0x02` | KeyStore |
+
+The 'KeyStore' level expects an EncryptionParameters section. The 'Derived'
+level expects an EncryptionParameters section **and** a DerivationParameters section.
+The 'None' level expects no additional sections.
+
+##### None
+
+No encryption at all. The content of the database is stored in plain text.
+
+##### Derived
+
+If this level is used, the key is derived from a user-provided password using
+PBKDF2 with SHA256 as the underlying PRF. The parameters used for PBKDF2 (salt,
+number of iterations) are stored in the plain section of this file. The key is
+not stored anywhere.
+
+##### KeyStore
+
+The key is kept in the Android keystore and can optionally be set up to require
+user authentication (fingerprint). This security level is only available on
+Android M and above.
+
+### Sections
+
+| Length | Contents |
+|:-------|:------------------|
+| `1` | `uint8_t` ID |
+| `4` | `uint32_t` Length |
+| `?` | Section data |
+
+ID can be one of:
+
+| Value | Name |
+|:-------|:---------------------|
+| `0x00` | EncryptionParameters |
+| `0x01` | DerivationParameters |
+| `0xFF` | End marker |
+
+#### EncryptionParameters
+
+| Length | Contents |
+|:-------|:---------|
+| `12` | Nonce |
+| `16` | Tag |
+
+#### DerivationParameters
+
+| Length | Contents |
+|:-------|:--------------------------------|
+| `8` | `uint64_t` Number of iterations |
+| `32` | Salt |
+
+#### End marker
+
+This section indicates the end of the list of sections. This section doesn't
+have any content and thus its length is 0.
+
+### Content
+
+The content of the database is a JSON file encoded in UTF-8. As mentioned
+above, it's encrypted.
+
+``` json
+{
+ "version": 1,
+ "entries":
+ [
+ {
+ "id": 1,
+ "name": "ACME Co/john@example.com",
+ "url": "otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30",
+ "order": 0,
+ },
+ ...
+ ]
+}
+```