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 @@ + + + + + + + + + + + + + + + +