Initial pass on adding support for persistence using sqlcipher

To keep the database schema simple we just save the
otpauth:// url and let KeyInfo take care of checking for
validity.

This patch also includes multiple fixes for the KeyInfo
class.

We still need a separate activity to allow the user to
enter their PIN/password. Currently, "test" is used as the
password for the database.
This commit is contained in:
Impyy 2016-08-22 19:31:03 +02:00
parent 5994be2e4d
commit 8063ba11f1
9 changed files with 210 additions and 15 deletions

View file

@ -32,5 +32,6 @@ dependencies {
compile 'com.android.support:support-v4:24.1.1' compile 'com.android.support:support-v4:24.1.1'
compile 'com.android.support:recyclerview-v7:24.1.1' compile 'com.android.support:recyclerview-v7:24.1.1'
compile 'com.yarolegovich:lovely-dialog:1.0.4' compile 'com.yarolegovich:lovely-dialog:1.0.4'
compile 'net.zetetic:android-database-sqlcipher:3.5.3@aar'
testCompile 'junit:junit:4.12' testCompile 'junit:junit:4.12'
} }

View file

@ -8,5 +8,6 @@ public class KeyProfile implements Serializable {
public String Name; public String Name;
public String Icon; public String Icon;
public String Code; public String Code;
public KeyInfo KeyInfo; public KeyInfo Info;
public int ID;
} }

View file

@ -74,7 +74,7 @@ public class KeyProfileAdapter extends RecyclerView.Adapter<KeyProfileAdapter.Ke
} }
String otp = ""; String otp = "";
try { try {
otp = OTP.generateOTP(this.keyProfile.KeyInfo); otp = OTP.generateOTP(this.keyProfile.Info);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
} }
@ -136,7 +136,7 @@ public class KeyProfileAdapter extends RecyclerView.Adapter<KeyProfileAdapter.Ke
} }
}); });
} }
}, holder.keyProfile.KeyInfo.getMillisTillNextRotation(), holder.keyProfile.KeyInfo.getPeriod() * 1000); }, holder.keyProfile.Info.getMillisTillNextRotation(), holder.keyProfile.Info.getPeriod() * 1000);
} }
// Return the size of your dataset (invoked by the layout manager) // Return the size of your dataset (invoked by the layout manager)

View file

@ -7,7 +7,6 @@ import android.content.Intent;
import android.support.design.widget.FloatingActionButton; import android.support.design.widget.FloatingActionButton;
import android.support.v7.app.AppCompatActivity; import android.support.v7.app.AppCompatActivity;
import android.os.Bundle; import android.os.Bundle;
import android.support.v7.app.AppCompatDelegate;
import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.Toolbar; import android.support.v7.widget.Toolbar;
@ -24,7 +23,9 @@ import java.util.ArrayList;
import github.nisrulz.recyclerviewhelper.RVHItemClickListener; import github.nisrulz.recyclerviewhelper.RVHItemClickListener;
import github.nisrulz.recyclerviewhelper.RVHItemDividerDecoration; import github.nisrulz.recyclerviewhelper.RVHItemDividerDecoration;
import github.nisrulz.recyclerviewhelper.RVHItemTouchHelperCallback; import github.nisrulz.recyclerviewhelper.RVHItemTouchHelperCallback;
import me.impy.aegis.crypto.CryptoUtils;
import me.impy.aegis.crypto.OTP; import me.impy.aegis.crypto.OTP;
import me.impy.aegis.db.Database;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
@ -32,6 +33,7 @@ public class MainActivity extends AppCompatActivity {
RecyclerView rvKeyProfiles; RecyclerView rvKeyProfiles;
KeyProfileAdapter mKeyProfileAdapter; KeyProfileAdapter mKeyProfileAdapter;
ArrayList<KeyProfile> mKeyProfiles; ArrayList<KeyProfile> mKeyProfiles;
Database database;
int count = 0; int count = 0;
@ -41,7 +43,7 @@ public class MainActivity extends AppCompatActivity {
setContentView(R.layout.activity_main); setContentView(R.layout.activity_main);
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
setSupportActionBar(toolbar); setSupportActionBar(toolbar);
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); //AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab); FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
fab.setOnClickListener(new View.OnClickListener() { fab.setOnClickListener(new View.OnClickListener() {
@ -52,6 +54,11 @@ public class MainActivity extends AppCompatActivity {
} }
}); });
// demo
char[] password = "test".toCharArray();
database = Database.createInstance(getApplicationContext(), "keys.db", password);
CryptoUtils.zero(password);
mKeyProfiles = new ArrayList<>(); mKeyProfiles = new ArrayList<>();
rvKeyProfiles = (RecyclerView) findViewById(R.id.rvKeyProfiles); rvKeyProfiles = (RecyclerView) findViewById(R.id.rvKeyProfiles);
@ -78,6 +85,15 @@ public class MainActivity extends AppCompatActivity {
helper.attachToRecyclerView(rvKeyProfiles); helper.attachToRecyclerView(rvKeyProfiles);
rvKeyProfiles.setAdapter(mKeyProfileAdapter); rvKeyProfiles.setAdapter(mKeyProfileAdapter);
try {
for (KeyProfile profile : database.getKeys()) {
mKeyProfiles.add(profile);
}
mKeyProfileAdapter.notifyDataSetChanged();
} catch (Exception e) {
e.printStackTrace();
}
} }
@Override @Override
@ -90,7 +106,7 @@ public class MainActivity extends AppCompatActivity {
String otp; String otp;
try { try {
otp = OTP.generateOTP(keyProfile.KeyInfo); otp = OTP.generateOTP(keyProfile.Info);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
return; return;
@ -117,6 +133,12 @@ public class MainActivity extends AppCompatActivity {
keyProfile.Name = text; keyProfile.Name = text;
mKeyProfiles.add(keyProfile); mKeyProfiles.add(keyProfile);
mKeyProfileAdapter.notifyDataSetChanged(); mKeyProfileAdapter.notifyDataSetChanged();
try {
database.addKey(keyProfile);
} catch (Exception e) {
e.printStackTrace();
}
} }
}) })
.show(); .show();

View file

@ -59,7 +59,7 @@ public class ScannerActivity extends Activity implements ZXingScannerView.Result
try { try {
KeyInfo info = KeyInfo.FromURL("otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"); KeyInfo info = KeyInfo.FromURL("otpauth://totp/ACME%20Co:john@example.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ");
KeyProfile keyProfile = new KeyProfile(); KeyProfile keyProfile = new KeyProfile();
keyProfile.KeyInfo = info; keyProfile.Info = info;
keyProfile.Name = String.format("%s/%s", info.getIssuer(), info.getAccountName()); keyProfile.Name = String.format("%s/%s", info.getIssuer(), info.getAccountName());
Intent resultIntent = new Intent(); Intent resultIntent = new Intent();
@ -84,7 +84,7 @@ public class ScannerActivity extends Activity implements ZXingScannerView.Result
//TODO: Handle non TOTP / HOTP qr codes. //TODO: Handle non TOTP / HOTP qr codes.
KeyInfo info = KeyInfo.FromURL(rawResult.getText()); KeyInfo info = KeyInfo.FromURL(rawResult.getText());
KeyProfile keyProfile = new KeyProfile(); KeyProfile keyProfile = new KeyProfile();
keyProfile.KeyInfo = info; keyProfile.Info = info;
keyProfile.Name = String.format("%s/%s", info.getIssuer(), info.getAccountName()); keyProfile.Name = String.format("%s/%s", info.getIssuer(), info.getAccountName());
Intent resultIntent = new Intent(); Intent resultIntent = new Intent();

View file

@ -0,0 +1,16 @@
package me.impy.aegis.crypto;
import java.util.Arrays;
public class CryptoUtils {
private CryptoUtils() {
}
public static void zero(char[] data) {
Arrays.fill(data, '\0');
}
public static void zero(byte[] data) {
Arrays.fill(data, (byte)0);
}
}

View file

@ -12,7 +12,7 @@ public class KeyInfo implements Serializable {
private String accountName; private String accountName;
private String issuer; private String issuer;
private long counter; private long counter;
private String algorithm = "HmacSHA1"; private String algorithm = "SHA1";
private int digits = 6; private int digits = 6;
private int period = 30; private int period = 30;
@ -29,7 +29,7 @@ public class KeyInfo implements Serializable {
return issuer; return issuer;
} }
public String getAlgorithm() { public String getAlgorithm() {
return algorithm; return "Hmac" + algorithm;
} }
public int getDigits() { public int getDigits() {
return digits; return digits;
@ -41,7 +41,34 @@ public class KeyInfo implements Serializable {
return period; return period;
} }
private KeyInfo() { } private KeyInfo() {
}
public void setCounter(long count) {
counter = count;
}
public String getURL() throws Exception {
Uri.Builder builder = new Uri.Builder();
builder.scheme("otpauth");
builder.authority(type);
builder.appendQueryParameter("period", Integer.toString(period));
builder.appendQueryParameter("algorithm", algorithm);
builder.appendQueryParameter("secret", Base32.encodeOriginal(secret));
if (type.equals("hotp")) {
builder.appendQueryParameter("counter", Long.toString(counter));
}
if (!issuer.equals("")) {
builder.path(String.format("%s:%s", issuer, accountName));
builder.appendQueryParameter("issuer", issuer);
} else {
builder.path(accountName);
}
return builder.build().toString();
}
public long getMillisTillNextRotation() { public long getMillisTillNextRotation() {
long p = period * 1000; long p = period * 1000;
@ -96,21 +123,21 @@ public class KeyInfo implements Serializable {
// just use the defaults if these parameters aren't set // just use the defaults if these parameters aren't set
String algorithm = url.getQueryParameter("algorithm"); String algorithm = url.getQueryParameter("algorithm");
if (algorithm != null) { if (algorithm != null) {
info.algorithm = "Hmac" + algorithm; info.algorithm = algorithm;
} }
String period = url.getQueryParameter("period"); String period = url.getQueryParameter("period");
if (period != null) { if (period != null) {
info.period = Integer.getInteger(period); info.period = Integer.parseInt(period);
} }
String digits = url.getQueryParameter("digits"); String digits = url.getQueryParameter("digits");
if (digits != null) { if (digits != null) {
info.digits = Integer.getInteger(digits); info.digits = Integer.parseInt(digits);
} }
// 'counter' is required if the type is 'hotp' // 'counter' is required if the type is 'hotp'
String counter = url.getQueryParameter("counter"); String counter = url.getQueryParameter("counter");
if (counter != null) { if (counter != null) {
info.counter = Long.getLong(counter); info.counter = Long.parseLong(counter);
} else if (info.type.equals("hotp")) { } else if (info.type.equals("hotp")) {
throw new Exception("'counter' was not set which is required for 'hotp'"); throw new Exception("'counter' was not set which is required for 'hotp'");
} }

View file

@ -0,0 +1,91 @@
package me.impy.aegis.db;
import android.content.Context;
import net.sqlcipher.Cursor;
import net.sqlcipher.database.SQLiteDatabase;
import java.util.ArrayList;
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 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;
}
if (instance == null) {
instance = new Database(context, filename, password);
}
return instance;
}
// 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");
}
public void updateKey(KeyProfile profile) throws Exception {
db.execSQL("update otp set name=? url=? where id=?",
new Object[]{ profile.Name, profile.Info.getURL(), profile.ID });
}
public void removeKey(KeyProfile profile) {
db.execSQL("delete from otp where id=?", new Object[]{ profile.ID });
}
public List<KeyProfile> getKeys() throws Exception {
List<KeyProfile> list = new ArrayList<>();
Cursor cursor = db.rawQuery("select * from otp", null);
try {
while (cursor.moveToNext()) {
KeyProfile profile = new KeyProfile();
profile.ID = cursor.getInt(cursor.getColumnIndexOrThrow("id"));
profile.Name = cursor.getString(cursor.getColumnIndexOrThrow("name"));
String url = cursor.getString(cursor.getColumnIndexOrThrow("url"));
profile.Info = KeyInfo.FromURL(url);
list.add(profile);
}
return list;
} finally {
cursor.close();
}
}
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");
}
return cursor.getInt(cursor.getColumnIndexOrThrow("id"));
} finally {
cursor.close();
}
}
}

View file

@ -0,0 +1,37 @@
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)";
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);
}
}