Replaced the sqlcipher database with a custom one
Still a WIP. This change was made to allow us to use Android's SecretKey API. Which in turn allows us to store our secret keys in the Android KeyStore. You can read about the database file format here: doc/db.md
18
Makefile
Normal file
|
@ -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
|
|
@ -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'
|
||||
}
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
|
||||
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/icon"
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package me.impy.aegis;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.security.Key;
|
||||
|
||||
import me.impy.aegis.crypto.KeyInfo;
|
||||
|
||||
|
@ -14,7 +13,6 @@ public class KeyProfile implements Serializable {
|
|||
public int Order;
|
||||
public int ID;
|
||||
|
||||
|
||||
public int compareTo(KeyProfile another) {
|
||||
if (this.Order>another.Order){
|
||||
return -1;
|
||||
|
|
|
@ -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<KeyProfileAdapter.KeyProfileHolder> implements ItemTouchHelperAdapter {
|
||||
|
|
|
@ -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<KeyProfile> mKeyProfiles;
|
||||
ArrayList<KeyProfile> 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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
package me.impy.aegis.crypto;
|
||||
|
||||
public class CryptParameters {
|
||||
public byte[] Nonce;
|
||||
public byte[] Tag;
|
||||
}
|
6
app/src/main/java/me/impy/aegis/crypto/CryptResult.java
Normal file
|
@ -0,0 +1,6 @@
|
|||
package me.impy.aegis.crypto;
|
||||
|
||||
public class CryptResult {
|
||||
public CryptParameters Parameters;
|
||||
public byte[] Data;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
package me.impy.aegis.crypto;
|
||||
|
||||
public class DerivationParameters {
|
||||
public long IterationCount;
|
||||
public byte[] Salt;
|
||||
}
|
53
app/src/main/java/me/impy/aegis/crypto/KeyStoreHandle.java
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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<DatabaseEntry> 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<KeyProfile> getKeys() throws Exception {
|
||||
List<KeyProfile> 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<KeyProfile>() {
|
||||
@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");
|
||||
}
|
||||
}
|
||||
}
|
27
app/src/main/java/me/impy/aegis/db/DatabaseEntry.java
Normal file
|
@ -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");
|
||||
}
|
||||
}
|
247
app/src/main/java/me/impy/aegis/db/DatabaseFile.java
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
150
app/src/main/java/me/impy/aegis/finger/FingerprintUiHelper.java
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
BIN
app/src/main/res/drawable-hdpi/ic_fp_40px.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
app/src/main/res/drawable-hdpi/tile.9.png
Normal file
After Width: | Height: | Size: 196 B |
BIN
app/src/main/res/drawable-mdpi/ic_fp_40px.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
app/src/main/res/drawable-nodpi/android_robot.png
Normal file
After Width: | Height: | Size: 8.1 KiB |
BIN
app/src/main/res/drawable-xhdpi/ic_fp_40px.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/drawable-xxhdpi/ic_fp_40px.png
Normal file
After Width: | Height: | Size: 18 KiB |
BIN
app/src/main/res/drawable-xxxhdpi/ic_fp_40px.png
Normal file
After Width: | Height: | Size: 16 KiB |
28
app/src/main/res/drawable/ic_fingerprint_error.xml
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="40.0dp"
|
||||
android:height="40.0dp"
|
||||
android:viewportWidth="40.0"
|
||||
android:viewportHeight="40.0">
|
||||
<path
|
||||
android:pathData="M20.0,0.0C8.96,0.0 0.0,8.95 0.0,20.0s8.96,20.0 20.0,20.0c11.04,0.0 20.0,-8.95 20.0,-20.0S31.04,0.0 20.0,0.0z"
|
||||
android:fillColor="#F4511E"/>
|
||||
<path
|
||||
android:pathData="M21.33,29.33l-2.67,0.0l0.0,-2.67l2.67,0.0L21.33,29.33zM21.33,22.67l-2.67,0.0l0.0,-12.0l2.67,0.0L21.33,22.67z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
28
app/src/main/res/drawable/ic_fingerprint_success.xml
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="40.0dp"
|
||||
android:height="40.0dp"
|
||||
android:viewportWidth="40.0"
|
||||
android:viewportHeight="40.0">
|
||||
<path
|
||||
android:pathData="M20.0,20.0m-20.0,0.0a20.0,20.0 0.0,1.0 1.0,40.0 0.0a20.0,20.0 0.0,1.0 1.0,-40.0 0.0"
|
||||
android:fillColor="#009688"/>
|
||||
<path
|
||||
android:pathData="M11.2,21.41l1.63,-1.619999 4.17,4.169998 10.59,-10.589999 1.619999,1.63 -12.209999,12.209999z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
76
app/src/main/res/layout/fingerprint_dialog_backup.xml
Normal file
|
@ -0,0 +1,76 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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
|
||||
-->
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/backup_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/description"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentTop="true"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
>
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/password_description"
|
||||
android:id="@+id/password_description"
|
||||
android:textColor="?android:attr/textColorSecondary" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/new_fingerprint_enrolled_description"
|
||||
android:id="@+id/new_fingerprint_enrolled_description"
|
||||
android:visibility="gone"
|
||||
android:textColor="?android:attr/textColorSecondary" />
|
||||
</FrameLayout>
|
||||
|
||||
<EditText
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:ems="10"
|
||||
android:hint="@string/password"
|
||||
android:imeOptions="actionGo"
|
||||
android:id="@+id/password"
|
||||
android:layout_below="@+id/description"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:layout_alignParentStart="true" />
|
||||
|
||||
<CheckBox
|
||||
android:id="@+id/use_fingerprint_in_future_check"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_below="@+id/password"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:checked="true"
|
||||
android:visibility="gone"
|
||||
android:text="@string/use_fingerprint_in_future" />
|
||||
|
||||
</RelativeLayout>
|
63
app/src/main/res/layout/fingerprint_dialog_container.xml
Normal file
|
@ -0,0 +1,63 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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
|
||||
-->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<include layout="@layout/fingerprint_dialog_content" />
|
||||
|
||||
<include
|
||||
layout="@layout/fingerprint_dialog_backup"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/buttonPanel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:gravity="bottom"
|
||||
style="?android:attr/buttonBarStyle">
|
||||
|
||||
<Space
|
||||
android:id="@+id/spacer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:visibility="invisible" />
|
||||
<Button
|
||||
android:id="@+id/cancel_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/second_dialog_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"/>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
56
app/src/main/res/layout/fingerprint_dialog_content.xml
Normal file
|
@ -0,0 +1,56 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ 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
|
||||
-->
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/fingerprint_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingBottom="8dp"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp"
|
||||
android:paddingTop="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/fingerprint_description"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_alignParentTop="true"
|
||||
android:text="@string/fingerprint_description"
|
||||
android:textColor="?android:attr/textColorSecondary"/>
|
||||
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/fingerprint_icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_below="@+id/fingerprint_description"
|
||||
android:layout_marginTop="20dp"
|
||||
android:src="@drawable/ic_fp_40px" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/fingerprint_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignBottom="@+id/fingerprint_icon"
|
||||
android:layout_alignTop="@+id/fingerprint_icon"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_toEndOf="@+id/fingerprint_icon"
|
||||
android:gravity="center_vertical"
|
||||
android:text="@string/fingerprint_hint"
|
||||
android:textColor="@color/hint_color" />
|
||||
</RelativeLayout>
|
|
@ -19,4 +19,8 @@
|
|||
<color name="primary_text_dark">#ffffff</color>
|
||||
<color name="secondary_text_dark">#FF5252</color>
|
||||
<color name="background_dark">#191919</color>
|
||||
|
||||
<color name="warning_color">#f4511e</color>
|
||||
<color name="hint_color">#42000000</color>
|
||||
<color name="success_color">#009688</color>
|
||||
</resources>
|
||||
|
|
|
@ -7,4 +7,28 @@
|
|||
<string name="pref_night_mode_description">Enable this to use darker colors</string>
|
||||
<string name="pref_issuers">Show the issuer</string>
|
||||
<string name="pref_issuers_description">Enable this to show the issuer next to the profile name</string>
|
||||
|
||||
<string name="cancel">Cancel</string>
|
||||
<string name="use_password">Use password</string>
|
||||
<string name="sign_in">Sign in</string>
|
||||
<string name="ok">Ok</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="fingerprint_description">Confirm fingerprint to continue</string>
|
||||
<string name="fingerprint_hint">Touch sensor</string>
|
||||
<string name="password_description">Enter your store password to continue</string>
|
||||
<string name="purchase">Purchase</string>
|
||||
<string name="purchase_not_invalidated">Purchase not invalidated</string>
|
||||
<string name="purchase_button_not_invalidated_description">
|
||||
You can proceed to purchase with this button \n even if a new fingerprint is enrolled
|
||||
</string>
|
||||
<string name="fingerprint_not_recognized">Fingerprint not recognized. Try again</string>
|
||||
<string name="fingerprint_success">Fingerprint recognized</string>
|
||||
<string name="item_title">White Mesh Pluto Backpack</string>
|
||||
<string name="item_price">$62.68</string>
|
||||
<string name="item_description">Mesh backpack in white. Black textile trim throughout.</string>
|
||||
<string name="purchase_done">Purchase successful</string>
|
||||
<string name="new_fingerprint_enrolled_description">A new fingerprint was added to this device, so your password is required.</string>
|
||||
<string name="use_fingerprint_in_future">Use fingerprint in the future</string>
|
||||
<string name="use_fingerprint_to_authenticate_title">Use fingerprint to authenticate</string>
|
||||
<string name="use_fingerprint_to_authenticate_key" >use_fingerprint_to_authenticate_key</string>
|
||||
</resources>
|
||||
|
|
119
doc/db.md
Normal file
|
@ -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,
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|