diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 00000000..94112253 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +otp-authenticator \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 00000000..96cc43ef --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml new file mode 100644 index 00000000..e7bedf33 --- /dev/null +++ b/.idea/copyright/profiles_settings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 00000000..8d2df476 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..efbef40d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + 1.8 + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..35b45b23 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 00000000..7f68460d --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..6564d52d --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000..796b96d1 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 00000000..556b0f1f --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,45 @@ +apply plugin: 'com.android.application' + +android { + compileSdkVersion 23 + buildToolsVersion "23.0.2" + + defaultConfig { + applicationId "net.bierbaumer.otp_authenticator" + minSdkVersion 15 + targetSdkVersion 23 + versionCode 1 + versionName "0.1" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + debug { + testCoverageEnabled = true + } + } +} + +dependencies { + compile fileTree(dir: 'libs', include: ['*.jar']) + testCompile 'junit:junit:4.12' + compile 'com.android.support:appcompat-v7:23.1.1' + compile 'com.android.support:design:23.1.1' + compile 'com.journeyapps:zxing-android-embedded:3.0.3@aar' + compile 'com.google.zxing:core:3.2.0' + compile 'commons-codec:commons-codec:1.5' + + androidTestCompile 'com.android.support:support-annotations:23.1.1' + androidTestCompile 'com.android.support.test:runner:0.4.1' + androidTestCompile 'com.android.support.test:rules:0.4.1' + androidTestCompile 'com.android.support.test.espresso:espresso-core:2.2.1' + androidTestCompile 'com.android.support.test.espresso:espresso-intents:2.2.1' + androidTestCompile 'com.android.support.test.uiautomator:uiautomator-v18:2.1.1' + + androidTestCompile 'org.hamcrest:hamcrest-library:1.3' + +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 00000000..d12ff2e1 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /Users/bb/Library/Android/sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/app/src/androidTest/AndroidManifest.xml b/app/src/androidTest/AndroidManifest.xml new file mode 100644 index 00000000..f326dc69 --- /dev/null +++ b/app/src/androidTest/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/app/src/androidTest/java/net/bierbaumer/otp_authenticator/ApplicationTest.java b/app/src/androidTest/java/net/bierbaumer/otp_authenticator/ApplicationTest.java new file mode 100644 index 00000000..7e9f1b73 --- /dev/null +++ b/app/src/androidTest/java/net/bierbaumer/otp_authenticator/ApplicationTest.java @@ -0,0 +1,158 @@ +package net.bierbaumer.otp_authenticator; + +import android.app.Application; +import android.content.Context; +import android.test.ApplicationTestCase; + +import org.apache.commons.codec.DecoderException; +import org.apache.commons.codec.binary.Base32; +import org.apache.commons.codec.binary.Hex; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.ArrayList; +import java.util.Arrays; + +import javax.crypto.BadPaddingException; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +public class ApplicationTest extends ApplicationTestCase { + public ApplicationTest() { + super(Application.class); + } + + public void testTOTPHelper(){ + byte[] b = "12345678901234567890".getBytes(); + assertEquals(94287082, TOTPHelper.generate(b, 59l, 8)); + assertEquals(7081804, TOTPHelper.generate(b, 1111111109l, 8)); + assertEquals(14050471, TOTPHelper.generate(b, 1111111111l, 8)); + assertEquals(89005924, TOTPHelper.generate(b, 1234567890l, 8)); + assertEquals(69279037, TOTPHelper.generate(b, 2000000000l, 8)); + assertEquals(65353130, TOTPHelper.generate(b, 20000000000l, 8)); + } + + + public void testEntry() throws JSONException { + byte secret[] = "Das System ist sicher".getBytes(); + String label = "5 von 5 Sterne"; + + String s = "{\"secret\":\""+ new String(new Base32().encode(secret)) +"\",\"label\":\"" + label + "\"}"; + + Entry e = new Entry(new JSONObject(s)); + assertTrue(Arrays.equals(secret, e.getSecret())); + assertEquals(label, e.getLabel()); + + assertEquals(s, e.toJSON()+""); + } + + + + public void testEntryURL() throws Exception { + try { + new Entry("DON'T CARE"); + assertTrue(false); + } catch (Exception e) { + } + + try { + new Entry("https://github.com/0xbb/"); + assertTrue(false); + } catch (Exception e) { + } + + try { + new Entry("otpauth://hotp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"); + assertTrue(false); + } + catch (Exception e){ + } + + try { + new Entry("otpauth://totp/ACME"); + assertTrue(false); + } + catch (Exception e){ + } + + Entry entry = new Entry("otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&ALGORITHM=SHA1&digits=6&period=30"); + assertEquals("ACME Co - ACME Co:john.doe@email.com", entry.getLabel()); + + assertEquals("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", new String(new Base32().encode(entry.getSecret()))); + } + + public void testSettingsHelper() throws KeyStoreException, CertificateException, NoSuchAlgorithmException, IOException { + Context context = getContext(); + + final KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + keyStore.deleteEntry("settings"); + + new File(context.getFilesDir() + "/" + SettingsHelper.SETTINGS_FILE).delete(); + new File(context.getFilesDir() + "/" + SettingsHelper.KEY_FILE).delete(); + + ArrayList b = SettingsHelper.load(context); + assertEquals(0, b.size()); + + + ArrayList a = new ArrayList<>(); + Entry e = new Entry(); + e.setLabel("label"); + e.setSecret("secret".getBytes()); + a.add(e); + + e = new Entry(); + e.setLabel("label2"); + e.setSecret("secret2".getBytes()); + a.add(e); + + SettingsHelper.store(context, a); + b = SettingsHelper.load(context); + + assertEquals(a, b); + + new File(context.getFilesDir() + "/" + SettingsHelper.SETTINGS_FILE).delete(); + new File(context.getFilesDir() + "/" + SettingsHelper.KEY_FILE).delete(); + } + + public void testEncryptionHelper() throws NoSuchPaddingException, BadPaddingException, InvalidKeyException, NoSuchAlgorithmException, IllegalBlockSizeException, UnsupportedEncodingException, InvalidAlgorithmParameterException, DecoderException { + + + // https://golang.org/src/crypto/cipher/gcm_test.go + String[][] testCases = new String[][]{ + new String []{"11754cd72aec309bf52f7687212e8957","3c819d9a9bed087615030b65","", "250327c674aaf477aef2675748cf6971" }, + new String []{"ca47248ac0b6f8372a97ac43508308ed","ffd2b598feabc9019262d2be","", "60d20404af527d248d893ae495707d1a" }, + new String []{"7fddb57453c241d03efbed3ac44e371c","ee283a3fc75575e33efd4887","d5de42b461646c255c87bd2962d3b9a2", "2ccda4a5415cb91e135c2a0f78c9b2fdb36d1df9b9d5e596f83e8b7f52971cb3" }, + new String []{"ab72c77b97cb5fe9a382d9fe81ffdbed","54cc7dc2c37ec006bcc6d1da","007c5e5b3e59df24a7c355584fc1518d", "0e1bde206a07a9c2c1b65300f8c649972b4401346697138c7a4891ee59867d0c" }, + new String []{"feffe9928665731c6d6a8f9467308308","cafebabefacedbaddecaf888","d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b391aafd255", "42831ec2217774244b7221b784d0d49ce3aa212f2c02a4e035c17e2329aca12e21d514b25466931c7d8f6a5aac84aa051ba30b396a0aac973d58e091473f59854d5c2af327cd64a62cf35abd2ba6fab4" }, + + }; + + for(String[] testCase: testCases){ + + SecretKeySpec k = new SecretKeySpec(new Hex().decode(testCase[0].getBytes()), "AES"); + IvParameterSpec iv = new IvParameterSpec(new Hex().decode(testCase[1].getBytes())); + + byte[] cipherTExt = EncryptionHelper.encrypt(k,iv,new Hex().decode(testCase[2].getBytes())); + String cipher = new String(new Hex().encode(cipherTExt)); + + assertEquals(cipher, testCase[3]); + + assertEquals(testCase[2], new String(new Hex().encode(EncryptionHelper.decrypt(k, iv, cipherTExt)))); + + } + } + + +} \ No newline at end of file diff --git a/app/src/androidTest/java/net/bierbaumer/otp_authenticator/MainActivityTest.java b/app/src/androidTest/java/net/bierbaumer/otp_authenticator/MainActivityTest.java new file mode 100644 index 00000000..ef66b92a --- /dev/null +++ b/app/src/androidTest/java/net/bierbaumer/otp_authenticator/MainActivityTest.java @@ -0,0 +1,483 @@ +package net.bierbaumer.otp_authenticator; + + +import android.app.Activity; +import android.app.Instrumentation; +import android.content.Intent; +import android.support.test.InstrumentationRegistry; +import android.support.test.espresso.ViewAction; +import android.support.test.espresso.action.GeneralLocation; +import android.support.test.espresso.action.GeneralSwipeAction; +import android.support.test.espresso.action.Press; +import android.support.test.espresso.action.Swipe; +import android.support.test.espresso.action.ViewActions; +import android.support.test.espresso.intent.Intents; +import android.support.test.uiautomator.UiCollection; +import android.support.test.uiautomator.UiDevice; +import android.support.test.uiautomator.UiObject; +import android.support.test.uiautomator.UiObjectNotFoundException; +import android.support.test.uiautomator.UiSelector; +import android.support.v7.widget.ActionBarContextView; +import android.test.ActivityInstrumentationTestCase2; +import android.test.ViewAsserts; +import android.util.Log; +import android.view.View; +import android.widget.ListView; + + +import org.apache.commons.codec.EncoderException; +import org.apache.commons.codec.binary.Base32; +import org.hamcrest.Description; +import org.hamcrest.Matcher; + +import org.hamcrest.Matchers; +import org.hamcrest.TypeSafeMatcher; +import org.junit.Before; +import org.junit.FixMethodOrder; +import org.junit.runners.MethodSorters; + +import java.util.ArrayList; + +import static android.support.test.espresso.Espresso.onData; +import static android.support.test.espresso.Espresso.onView; +import static android.support.test.espresso.Espresso.openActionBarOverflowOrOptionsMenu; +import static android.support.test.espresso.action.ViewActions.actionWithAssertions; +import static android.support.test.espresso.action.ViewActions.clearText; +import static android.support.test.espresso.action.ViewActions.click; +import static android.support.test.espresso.action.ViewActions.longClick; +import static android.support.test.espresso.action.ViewActions.pressBack; +import static android.support.test.espresso.action.ViewActions.swipeDown; +import static android.support.test.espresso.action.ViewActions.typeText; +import static android.support.test.espresso.assertion.ViewAssertions.doesNotExist; +import static android.support.test.espresso.assertion.ViewAssertions.matches; +import static android.support.test.espresso.intent.Intents.intended; +import static android.support.test.espresso.intent.Intents.intending; +import static android.support.test.espresso.intent.matcher.IntentMatchers.hasAction; +import static android.support.test.espresso.intent.matcher.IntentMatchers.hasExtra; +import static android.support.test.espresso.matcher.ViewMatchers.isDescendantOfA; +import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; +import static android.support.test.espresso.matcher.ViewMatchers.withClassName; +import static android.support.test.espresso.matcher.ViewMatchers.withId; +import static android.support.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anything; +import static org.hamcrest.Matchers.is; + +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class MainActivityTest extends ActivityInstrumentationTestCase2 { + private MainActivity mActivity; + + public MainActivityTest() { + super(MainActivity.class); + } + + @Before + public void setUp() throws Exception { + super.setUp(); + injectInstrumentation(InstrumentationRegistry.getInstrumentation()); + mActivity = getActivity(); + } + + public void testStart(){ + ViewAsserts.assertOnScreen(mActivity.getWindow().getDecorView(), mActivity.findViewById(R.id.listView)); + } + + + //TODO. switch to toolbar + public void test000About() throws InterruptedException { + openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getContext()); + + onView(allOf(withText("About"), isDisplayed())).perform(click()); + + Thread.sleep(1000); + onView(withId(R.id.webViewAbout)).check(matches(isDisplayed())); + onView(withId(R.id.webViewAbout)).perform(pressBack()); + + onView(withId(R.id.webViewAbout)).check(doesNotExist()); + + } + + public void test000EmptyStart() throws InterruptedException { + + onView(withText("No Account has been added yet")).check(matches(isDisplayed())); + onView(withText("Add")).check(matches(isDisplayed())); + + Intents.init(); + + String qr = "XXX" ; + + // Build a result to return from the ZXING app + Intent resultData = new Intent(); + resultData.putExtra(com.google.zxing.client.android.Intents.Scan.RESULT, qr); + Instrumentation.ActivityResult result = new Instrumentation.ActivityResult(Activity.RESULT_OK, resultData); + + // Stub out the Camera. When an intent is sent to the Camera, this tells Espresso to respond + // with the ActivityResult we just created + intending(hasAction("com.google.zxing.client.android.SCAN")).respondWith(result); + + + onView(withText("Add")).check(matches(isDisplayed())); + + onView(withText("Add")).perform(click()); + + // We can also validate that an intent resolving to the "camera" activity has been sent out by our app + intended(hasAction("com.google.zxing.client.android.SCAN")); + + + Intents.release(); + } + + public void test001InvalidQRCode() throws InterruptedException { + Intents.init(); + + String qr ="invalid qr code"; + + // Build a result to return from the ZXING app + Intent resultData = new Intent(); + resultData.putExtra(com.google.zxing.client.android.Intents.Scan.RESULT, qr); + Instrumentation.ActivityResult result = new Instrumentation.ActivityResult(Activity.RESULT_OK, resultData); + + // Stub out the Camera. When an intent is sent to the Camera, this tells Espresso to respond + // with the ActivityResult we just created + intending(hasAction("com.google.zxing.client.android.SCAN")).respondWith(result); + + // Now that we have the stub in place, click on the button in our app that launches into the Camera + onView(withId(R.id.action_scan)).perform(click()); + + // We can also validate that an intent resolving to the "camera" activity has been sent out by our app + intended(hasAction("com.google.zxing.client.android.SCAN")); + + onView(withText("Invalid QR Code")).check(matches(isDisplayed())); + + + Thread.sleep(5000); + onView(withText("No Account has been added yet")).check(matches(isDisplayed())); + + Intents.release(); + } + + public void test002NocodeScanned() throws InterruptedException { + Intents.init(); + + // Build a result to return from the ZXING app + Intent resultData = new Intent(); + + Instrumentation.ActivityResult result = new Instrumentation.ActivityResult(Activity.RESULT_CANCELED, resultData); + + // Stub out the Camera. When an intent is sent to the Camera, this tells Espresso to respond + // with the ActivityResult we just created + intending(hasAction("com.google.zxing.client.android.SCAN")).respondWith(result); + + // Now that we have the stub in place, click on the button in our app that launches into the Camera + onView(withId(R.id.action_scan)).perform(click()); + + // We can also validate that an intent resolving to the "camera" activity has been sent out by our app + intended(hasAction("com.google.zxing.client.android.SCAN")); + + onView(withText("No Account has been added yet")).check(matches(isDisplayed())); + + Intents.release(); + } + + + String[][] codes = new String [][]{ + new String[]{"WOW", "Sicherheit00000"}, + new String[]{"SUCH", "Sicherheit00001"}, + new String[]{"APP", "Sicherheit00002"}, + }; + + + public void test003AddCodes() throws InterruptedException { + for(String[] code: codes){ + Intents.init(); + + String qr = "otpauth://totp/"+code[0] +"?secret="+new String(new Base32().encode(code[1].getBytes())) ; + + // Build a result to return from the ZXING app + Intent resultData = new Intent(); + resultData.putExtra(com.google.zxing.client.android.Intents.Scan.RESULT, qr); + Instrumentation.ActivityResult result = new Instrumentation.ActivityResult(Activity.RESULT_OK, resultData); + + // Stub out the Camera. When an intent is sent to the Camera, this tells Espresso to respond + // with the ActivityResult we just created + intending(hasAction("com.google.zxing.client.android.SCAN")).respondWith(result); + + // Now that we have the stub in place, click on the button in our app that launches into the Camera + onView(withId(R.id.action_scan)).perform(click()); + + // We can also validate that an intent resolving to the "camera" activity has been sent out by our app + intended(hasAction("com.google.zxing.client.android.SCAN")); + + onView(withText("Account added")).check(matches(isDisplayed())); + + Intents.release(); + } + + Thread.sleep(500); + + + for(int i = 0; i < codes.length; i++){ + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(i) + .onChildView(withId(R.id.textViewLabel)) + .check(matches(withText(codes[i][0]))); + + String otp = TOTPHelper.generate(codes[i][1].getBytes()); + + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(i) + .onChildView(withId(R.id.textViewOTP)) + .check(matches(withText(otp))); + } + } + + public void test003CodesChange() throws InterruptedException { + ArrayList oldCodes = new ArrayList<>(); + + for(int i = 0; i < codes.length; i++){ + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(i) + .onChildView(withId(R.id.textViewLabel)) + .check(matches(withText(codes[i][0]))); + + String otp = TOTPHelper.generate(codes[i][1].getBytes()); + oldCodes.add(otp); + + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(i) + .onChildView(withId(R.id.textViewOTP)) + .check(matches(withText(otp))); + } + + Thread.sleep(30*1000); + + for(int i = 0; i < codes.length; i++){ + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(i) + .onChildView(withId(R.id.textViewLabel)) + .check(matches(withText(codes[i][0]))); + + String otp = TOTPHelper.generate(codes[i][1].getBytes()); + assertTrue(!oldCodes.get(i).equals(otp)); + + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(i) + .onChildView(withId(R.id.textViewOTP)) + .check(matches(withText(otp))); + } + + + } + + public void test004Rearrange() throws InterruptedException, UiObjectNotFoundException { + UiDevice mDevice = UiDevice.getInstance(getInstrumentation()); + + + UiObject start = mDevice.findObject(new UiSelector().textContains(codes[0][0])); + UiObject end = mDevice.findObject(new UiSelector().textContains(codes[1][0])); + + start.dragTo(start, 10); + start.dragTo(end, 10); + + mDevice.pressBack(); + + Thread.sleep(2000); + + String t = codes[0][0]; + codes[0][0] = codes[1][0]; + codes[1][0] = t; + + for(int i = 0; i < codes.length; i++){ + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(i) + .onChildView(withId(R.id.textViewLabel)) + .check(matches(withText(codes[i][0]))); + } + + start = mDevice.findObject(new UiSelector().textContains(codes[0][0])); + end = mDevice.findObject(new UiSelector().textContains(codes[1][0])); + + start.dragTo(start, 10); + start.dragTo(end, 10); + + mDevice.pressBack(); + + Thread.sleep(2000); + + t = codes[0][0]; + codes[0][0] = codes[1][0]; + codes[1][0] = t; + + for(int i = 0; i < codes.length; i++){ + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(i) + .onChildView(withId(R.id.textViewLabel)) + .check(matches(withText(codes[i][0]))); + } + } + + public void test005EditMode() throws InterruptedException { + + onView(withId(R.id.action_edit)).check(doesNotExist()); + + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(0) + .perform(longClick()); + + onView(withId(R.id.action_edit)).check(matches(isDisplayed())); + onView(withId(R.id.action_delete)).check(matches(isDisplayed())); + ActionBarContextView.class.getCanonicalName(); + onView(allOf(isDescendantOfA(withClassName(Matchers.containsString("ActionBarContextView"))), withText(codes[0][0]))).check(matches(isDisplayed())); + + + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(1) + .perform(longClick()); + + + + onView(withId(R.id.action_edit)).check(matches(isDisplayed())); + onView(withId(R.id.action_delete)).check(matches(isDisplayed())); + onView(allOf(isDescendantOfA(withClassName(Matchers.containsString("ActionBarContextView"))), withText(codes[1][0]))).check(matches(isDisplayed())); + + + onView(withId(R.id.listView)).perform(pressBack()); + + onView(withId(R.id.action_edit)).check(doesNotExist()); + } + + public void test005RenameCancel(){ + + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(1) + .perform(longClick()); + + onView(withId(R.id.action_edit)).check(matches(isDisplayed())); + + onView(withId(R.id.action_edit)).perform(click()); + + onView(withText(codes[1][0])).perform(click()).perform(typeText(" VERY TEST")); + + onView(withText("Cancel")).perform(click()); + + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(1) + .onChildView(withId(R.id.textViewLabel)) + .check(matches(withText(codes[1][0]))); + + } + + public void test006Rename(){ + + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(1) + .perform(longClick()); + + onView(withId(R.id.action_edit)).check(matches(isDisplayed())); + + onView(withId(R.id.action_edit)).perform(click()); + + onView(withText(codes[1][0])).perform(click()).perform(typeText(" VERY TEST")); + + onView(withText("Save")).perform(click()); + + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(1) + .onChildView(withId(R.id.textViewLabel)) + .check(matches(withText(codes[1][0] + " VERY TEST"))); + + } + + + public void test007DeleteCancel() throws InterruptedException, EncoderException { + + + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(0) + .perform(longClick()); + + onView(withId(R.id.action_delete)).check(matches(isDisplayed())); + onView(withId(R.id.action_delete)).perform(click()); + + onView(withText("Remove")).check(matches(isDisplayed())); + onView(withText("Cancel")).check(matches(isDisplayed())); + + onView(withText("Cancel")).perform(click()); + + onView(withText("Remove")).check(doesNotExist()); + + onView(withId(R.id.listView)).check(matches(withListSize(codes.length))); + + } + + public void test008Delete() throws InterruptedException, EncoderException { + + + + + // remove test + for(int i = codes.length; i > 0; i--){ + + onData(anything()).inAdapterView(withId(R.id.listView)) + .atPosition(0) + .perform(longClick()); + + onView(withId(R.id.action_delete)).check(matches(isDisplayed())); + onView(withId(R.id.action_delete)).perform(click()); + + onView(withText("Remove")).check(matches(isDisplayed())); + + + + onView(withText("Remove")).perform(click()); + onView(withId(R.id.listView)).check(matches(withListSize(i - 1))); + + if(i > 1){ + onView(withText("Account removed")).check(matches(isDisplayed())); + } + else { + onView(withText(R.string.no_accounts)).check(matches(isDisplayed())); + } + + } + + + } + + public static Matcher withListSize (final int size) { + return new TypeSafeMatcher () { + @Override public boolean matchesSafely (final View view) { + return ((ListView) view).getChildCount () == size; + } + + @Override public void describeTo (final Description description) { + description.appendText ("ListView should have " + size + " items"); + } + }; + } + + + public static Matcher withResourceName(String resourceName) { + return withResourceName(is(resourceName)); + } + + public static Matcher withResourceName(final Matcher resourceNameMatcher) { + return new TypeSafeMatcher() { + @Override + public void describeTo(Description description) { + description.appendText("with resource name: "); + resourceNameMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + int id = view.getId(); + return id != View.NO_ID && id != 0 && view.getResources() != null + && resourceNameMatcher.matches(view.getResources().getResourceName(id)); + } + }; + } + + + +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..ac90f201 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png new file mode 100644 index 00000000..2136ccd4 Binary files /dev/null and b/app/src/main/ic_launcher-web.png differ diff --git a/app/src/main/java/net/bierbaumer/otp_authenticator/CaptureActivityAnyOrientation.java b/app/src/main/java/net/bierbaumer/otp_authenticator/CaptureActivityAnyOrientation.java new file mode 100644 index 00000000..b1b9b1cc --- /dev/null +++ b/app/src/main/java/net/bierbaumer/otp_authenticator/CaptureActivityAnyOrientation.java @@ -0,0 +1,7 @@ +package net.bierbaumer.otp_authenticator; + +import com.journeyapps.barcodescanner.CaptureActivity; + +public class CaptureActivityAnyOrientation extends CaptureActivity { + +} diff --git a/app/src/main/java/net/bierbaumer/otp_authenticator/EncryptionHelper.java b/app/src/main/java/net/bierbaumer/otp_authenticator/EncryptionHelper.java new file mode 100644 index 00000000..acd5aa59 --- /dev/null +++ b/app/src/main/java/net/bierbaumer/otp_authenticator/EncryptionHelper.java @@ -0,0 +1,96 @@ +package net.bierbaumer.otp_authenticator; + +import android.content.Context; + +import java.io.File; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +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.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import static net.bierbaumer.otp_authenticator.Utils.readFully; +import static net.bierbaumer.otp_authenticator.Utils.writeFully; + +public class EncryptionHelper { + public final static int KEY_LENGTH = 16; + public final static int IV_LENGTH = 12; + + public final static String ALGORITHM = "AES/GCM/NoPadding"; + + public static byte[] encrypt(SecretKey secretKey, IvParameterSpec iv, byte[] plainText) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, UnsupportedEncodingException, BadPaddingException, IllegalBlockSizeException { + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, iv); + + return cipher.doFinal(plainText); + } + + public static byte[] decrypt(SecretKey secretKey, IvParameterSpec iv, byte[] cipherText) throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, InvalidKeyException, BadPaddingException, IllegalBlockSizeException { + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, secretKey, iv); + + return cipher.doFinal(cipherText); + } + + + public static byte[] encrypt(SecretKey secretKey, byte[] plaintext) throws NoSuchPaddingException, BadPaddingException, InvalidKeyException, NoSuchAlgorithmException, IllegalBlockSizeException, UnsupportedEncodingException, InvalidAlgorithmParameterException { + final byte[] iv = new byte[IV_LENGTH]; + new SecureRandom().nextBytes(iv); + + byte[] cipherText = encrypt(secretKey, new IvParameterSpec(iv), plaintext); + + + byte[] combined = new byte[iv.length + cipherText.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(cipherText, 0, combined, iv.length, cipherText.length); + + return combined; + } + + public static byte[] decrypt(SecretKey secretKey, byte[] cipherText) throws NoSuchPaddingException, InvalidKeyException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException, InvalidAlgorithmParameterException { + byte[] iv = Arrays.copyOfRange(cipherText, 0, IV_LENGTH); + byte[] cipher = Arrays.copyOfRange(cipherText, IV_LENGTH,cipherText.length); + + return decrypt(secretKey,new IvParameterSpec(iv), cipher ); + } + + /** + * Load our symmetric secret key. + * The symmetric secret key is stored securely on disk by wrapping + * it with a public/private key pair, possibly backed by hardware. + */ + public static SecretKey loadOrGenerateKeys(Context context, File keyFile) + throws GeneralSecurityException, IOException { + final SecretKeyWrapper wrapper = new SecretKeyWrapper(context, "settings"); + + // Generate secret key if none exists + if (!keyFile.exists()) { + final byte[] raw = new byte[KEY_LENGTH]; + new SecureRandom().nextBytes(raw); + + final SecretKey key = new SecretKeySpec(raw, "AES"); + final byte[] wrapped = wrapper.wrap(key); + + + writeFully(keyFile, wrapped); + } + + // Even if we just generated the key, always read it back to ensure we + // can read it successfully. + final byte[] wrapped = readFully(keyFile); + final SecretKey key = wrapper.unwrap(wrapped); + + return key; + } +} diff --git a/app/src/main/java/net/bierbaumer/otp_authenticator/EntriesAdapter.java b/app/src/main/java/net/bierbaumer/otp_authenticator/EntriesAdapter.java new file mode 100644 index 00000000..84309cb9 --- /dev/null +++ b/app/src/main/java/net/bierbaumer/otp_authenticator/EntriesAdapter.java @@ -0,0 +1,134 @@ +package net.bierbaumer.otp_authenticator; + +import android.content.ClipData; +import android.graphics.Color; +import android.view.DragEvent; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; + +import java.util.List; + + +public class EntriesAdapter extends BaseAdapter { + private List entries; + private Entry currentSelection; + + + @Override + public int getCount() { + return getEntries().size(); + } + + @Override + public Entry getItem(int i) { + return getEntries().get(i); + } + + @Override + public long getItemId(int i) { + return i; + } + + @Override + public View getView(final int position, View convertView, final ViewGroup parent) { + + View v = convertView; + + if (v == null) { + final LayoutInflater vi; + vi = LayoutInflater.from(parent.getContext()); + v = vi.inflate(R.layout.row, null); + } + + v.setBackgroundColor(Color.TRANSPARENT); + + if(getEntries().get(position) == getCurrentSelection()){ + v.setBackgroundColor(parent.getResources().getColor(R.color.primary_light)); + } + + + + final TextView tt1 = (TextView) v.findViewById(R.id.textViewLabel); + tt1.setText(getItem(position).getLabel()); + + TextView tt2 = (TextView) v.findViewById(R.id.textViewOTP); + v.setTag(position); + tt2.setText(getItem(position).getCurrentOTP()); + + + v.setOnDragListener(new View.OnDragListener() { + + @Override + public boolean onDrag(View v, DragEvent event) { + + final int action = event.getAction(); + switch (action) { + case DragEvent.ACTION_DRAG_STARTED: + break; + + case DragEvent.ACTION_DRAG_EXITED: + break; + + case DragEvent.ACTION_DRAG_ENTERED: + break; + + case DragEvent.ACTION_DROP: { + int from = Integer.parseInt(event.getClipData().getDescription().getLabel()+""); + int to = (Integer) v.getTag(); + Entry e = getEntries().remove(from); + getEntries().add(to, e); + notifyDataSetChanged(); + + return true; + } + + case DragEvent.ACTION_DRAG_ENDED: { + return true; + } + + default: + break; + } + return true; + } + }); + v.setOnTouchListener(new View.OnTouchListener() { + + @Override + public boolean onTouch(View v, MotionEvent arg1) { + + if (getCurrentSelection() != getEntries().get(position)) { + return false; + } + + ClipData data = ClipData.newPlainText(v.getTag() + "", ""); + View.DragShadowBuilder shadow = new View.DragShadowBuilder(v); + v.startDrag(data, shadow, null, 0); + + return false; + } + }); + + return v; + } + + public List getEntries() { + return entries; + } + + public void setEntries(List entries) { + this.entries = entries; + } + + public Entry getCurrentSelection() { + return currentSelection; + } + + public void setCurrentSelection(Entry currentSelection) { + this.currentSelection = currentSelection; + } +} \ No newline at end of file diff --git a/app/src/main/java/net/bierbaumer/otp_authenticator/Entry.java b/app/src/main/java/net/bierbaumer/otp_authenticator/Entry.java new file mode 100644 index 00000000..a9da4dee --- /dev/null +++ b/app/src/main/java/net/bierbaumer/otp_authenticator/Entry.java @@ -0,0 +1,105 @@ +package net.bierbaumer.otp_authenticator; + +import android.net.Uri; + +import org.apache.commons.codec.binary.Base32; +import org.json.JSONException; +import org.json.JSONObject; + +import java.net.URL; +import java.util.Arrays; + +public class Entry { + public static final String JSON_SECRET = "secret"; + public static final String JSON_LABEL = "label"; + + private byte[] secret; + private String label; + private String currentOTP; + + public Entry (){ + + } + + public Entry(String contents) throws Exception { + contents = contents.replaceFirst("otpauth", "http"); + Uri uri = Uri.parse(contents); + URL url = new URL(contents); + + if(!url.getProtocol().equals("http")){ + throw new Exception("Invalid Protocol"); + } + + if(!url.getHost().equals("totp")){ + throw new Exception(); + } + + String secret = uri.getQueryParameter("secret"); + String label = uri.getPath().substring(1); + + String issuer = uri.getQueryParameter("issuer"); + + if(issuer != null){ + label = issuer +" - "+label; + } + + this.label = label; + this.secret = new Base32().decode(secret.toUpperCase()); + } + + public Entry (JSONObject jsonObj ) throws JSONException { + this.setSecret(new Base32().decode(jsonObj.getString(JSON_SECRET))); + this.setLabel(jsonObj.getString(JSON_LABEL)); + } + + public JSONObject toJSON() throws JSONException { + JSONObject jsonObj = new JSONObject(); + jsonObj.put(JSON_SECRET, new String(new Base32().encode(getSecret()))); + jsonObj.put(JSON_LABEL, getLabel()); + + return jsonObj; + } + + public byte[] getSecret() { + return secret; + } + + public void setSecret(byte[] secret) { + this.secret = secret; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getCurrentOTP() { + return currentOTP; + } + + public void setCurrentOTP(String currentOTP) { + this.currentOTP = currentOTP; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + Entry entry = (Entry) o; + + if (!Arrays.equals(secret, entry.secret)) return false; + return !(label != null ? !label.equals(entry.label) : entry.label != null); + + } + + @Override + public int hashCode() { + int result = secret != null ? Arrays.hashCode(secret) : 0; + result = 31 * result + (label != null ? label.hashCode() : 0); + return result; + } +} diff --git a/app/src/main/java/net/bierbaumer/otp_authenticator/MainActivity.java b/app/src/main/java/net/bierbaumer/otp_authenticator/MainActivity.java new file mode 100644 index 00000000..2df4cbca --- /dev/null +++ b/app/src/main/java/net/bierbaumer/otp_authenticator/MainActivity.java @@ -0,0 +1,281 @@ +package net.bierbaumer.otp_authenticator; + +import android.animation.ObjectAnimator; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.support.design.widget.Snackbar; +import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.Toolbar; +import android.util.Log; +import android.view.ActionMode; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.WindowManager; +import android.view.animation.LinearInterpolator; +import android.webkit.WebView; +import android.widget.AdapterView; +import android.widget.EditText; +import android.widget.ListView; +import android.widget.ProgressBar; + +import com.google.zxing.client.android.Intents; +import com.google.zxing.integration.android.IntentIntegrator; + +import java.util.ArrayList; + +public class MainActivity extends AppCompatActivity implements ActionMode.Callback { + + private ArrayList entries; + private EntriesAdapter adapter; + + private Entry nextSelection = null; + private void showNoAccount(){ + Snackbar noAccountSnackbar = Snackbar.make(findViewById(R.id.listView), R.string.no_accounts, Snackbar.LENGTH_INDEFINITE) + .setAction("Add", new View.OnClickListener() { + @Override + public void onClick(View view) { + scanQRCode(); + } + }); + noAccountSnackbar.show(); + } + + private void scanQRCode(){ + new IntentIntegrator(MainActivity.this) + .setCaptureActivity(CaptureActivityAnyOrientation.class) + .setOrientationLocked(false) + .initiateScan(); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setTitle(R.string.app_name); + getWindow().setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE); + setContentView(R.layout.activity_main); + Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + final ListView listView = (ListView) findViewById(R.id.listView); + final ProgressBar progressBar = (ProgressBar) findViewById(R.id.progressBar); + + entries = SettingsHelper.load(this); + + adapter = new EntriesAdapter(); + adapter.setEntries(entries); + + listView.setAdapter(adapter); + + listView.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { + @Override + public boolean onItemLongClick(AdapterView adapterView, View view, int i, long l) { + nextSelection = entries.get(i); + startActionMode(MainActivity.this); + + return true; + } + }); + + if(entries.isEmpty()){ + showNoAccount(); + } + + int progress = ((int) (System.currentTimeMillis() / 1000) % 30); + progressBar.setProgress((int) (1000.0 * progress / 30.0)); + + ObjectAnimator animation = ObjectAnimator.ofInt(progressBar, "progress", 1000); + animation.setDuration((30 - progress) * 1000); + animation.setInterpolator(new LinearInterpolator()); + animation.start(); + + + final Handler handler = new Handler(); + final Runnable handlerTask = new Runnable() + { + @Override + public void run() { + int progress = (int) (System.currentTimeMillis() / 1000) % 30 ; + + if(progress == 0){ + progressBar.setProgress(progress); + + ObjectAnimator animation = ObjectAnimator.ofInt(progressBar, "progress", 1000); + animation.setDuration(30 * 1000); + animation.setInterpolator(new LinearInterpolator()); + animation.start(); + } + + for(int i =0;i< adapter.getCount();i++){ + if(progress == 0 || adapter.getItem(i).getCurrentOTP() == null){ + adapter.getItem(i).setCurrentOTP(TOTPHelper.generate(adapter.getItem(i).getSecret())); + } + } + adapter.notifyDataSetChanged(); + + handler.postDelayed(this, 1000); + } + }; + handlerTask.run(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent intent) { + super.onActivityResult(requestCode, resultCode, intent); + + if (requestCode == IntentIntegrator.REQUEST_CODE && resultCode == Activity.RESULT_OK) { + try { + Entry e = new Entry(intent.getStringExtra(Intents.Scan.RESULT)); + e.setCurrentOTP(TOTPHelper.generate(e.getSecret())); + entries.add(e); + SettingsHelper.store(this, entries); + + adapter.notifyDataSetChanged(); + + Snackbar.make(findViewById(R.id.listView), "Account added", Snackbar.LENGTH_LONG).show(); + } catch (Exception e) { + Snackbar.make(findViewById(R.id.listView), "Invalid QR Code", Snackbar.LENGTH_LONG).setCallback(new Snackbar.Callback() { + @Override + public void onDismissed(Snackbar snackbar, int event) { + super.onDismissed(snackbar, event); + + if(entries.isEmpty()){ + showNoAccount(); + } + } + }).show(); + + return; + } + } + + if(entries.isEmpty()){ + showNoAccount(); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + getMenuInflater().inflate(R.menu.menu_main, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int id = item.getItemId(); + + if (id == R.id.action_scan) { + scanQRCode(); + + return true; + } else if(id == R.id.action_about){ + WebView view = (WebView) LayoutInflater.from(this).inflate(R.layout.dialog_about, null); + view.loadUrl("file:///android_res/raw/about.html"); + new AlertDialog.Builder(this).setView(view).show(); + + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { + MenuInflater inflater = actionMode.getMenuInflater(); + inflater.inflate(R.menu.menu_edit, menu); + + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { + adapter.setCurrentSelection(nextSelection); + adapter.notifyDataSetChanged(); + actionMode.setTitle(adapter.getCurrentSelection().getLabel()); + + return true; + } + + @Override + public boolean onActionItemClicked(final ActionMode actionMode, MenuItem menuItem) { + int id = menuItem.getItemId(); + + if (id == R.id.action_delete) { + AlertDialog.Builder alert = new AlertDialog.Builder(this); + alert.setTitle("Remove " + adapter.getCurrentSelection().getLabel() + "?"); + alert.setMessage("Are you sure you want do remove this account?"); + + alert.setPositiveButton("Remove", new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int whichButton) { + entries.remove(adapter.getCurrentSelection()); + + Snackbar.make(findViewById(R.id.listView), "Account removed", Snackbar.LENGTH_LONG).setCallback(new Snackbar.Callback() { + @Override + public void onDismissed(Snackbar snackbar, int event) { + super.onDismissed(snackbar, event); + + if (entries.isEmpty()) { + showNoAccount(); + } + } + }).show(); + + actionMode.finish(); + } + }); + + alert.setNegativeButton("Cancel", new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int whichButton) { + dialog.cancel(); + actionMode.finish(); + } + }); + + alert.show(); + + return true; + } + else if (id == R.id.action_edit) { + AlertDialog.Builder alert = new AlertDialog.Builder(this); + alert.setTitle("Rename"); + + final EditText input = new EditText(this); + input.setText(adapter.getCurrentSelection().getLabel()); + alert.setView(input); + + alert.setPositiveButton("Save", new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int whichButton) { + adapter.getCurrentSelection().setLabel(input.getEditableText().toString()); + actionMode.finish(); + } + }); + + alert.setNegativeButton("Cancel", new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int whichButton) { + dialog.cancel(); + actionMode.finish(); + } + }); + + alert.show(); + + return true; + } + + return false; + } + + @Override + public void onDestroyActionMode(ActionMode actionMode) { + adapter.setCurrentSelection(null); + adapter.notifyDataSetChanged(); + + SettingsHelper.store(this, entries); + } +} \ No newline at end of file diff --git a/app/src/main/java/net/bierbaumer/otp_authenticator/SecretKeyWrapper.java b/app/src/main/java/net/bierbaumer/otp_authenticator/SecretKeyWrapper.java new file mode 100644 index 00000000..0a322c4d --- /dev/null +++ b/app/src/main/java/net/bierbaumer/otp_authenticator/SecretKeyWrapper.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2013 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. + */ + +package net.bierbaumer.otp_authenticator; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Build; +import android.security.KeyPairGeneratorSpec; + +import java.io.IOException; +import java.math.BigInteger; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.util.Calendar; +import java.util.GregorianCalendar; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.security.auth.x500.X500Principal; + +/** + * Wraps {@link SecretKey} instances using a public/private key pair stored in + * the platform {@link KeyStore}. This allows us to protect symmetric keys with + * hardware-backed crypto, if provided by the device. + *

+ * See key wrapping for more + * details. + *

+ * Not inherently thread safe. + */ +public class SecretKeyWrapper { + private final Cipher mCipher; + private final KeyPair mPair; + + /** + * Create a wrapper using the public/private key pair with the given alias. + * If no pair with that alias exists, it will be generated. + */ + public SecretKeyWrapper(Context context, String alias) + throws GeneralSecurityException, IOException { + mCipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + + final KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore"); + keyStore.load(null); + + if (!keyStore.containsAlias(alias)) { + generateKeyPair(context, alias); + } + + // Even if we just generated the key, always read it back to ensure we + // can read it successfully. + final KeyStore.PrivateKeyEntry entry = (KeyStore.PrivateKeyEntry) keyStore.getEntry( + alias, null); + mPair = new KeyPair(entry.getCertificate().getPublicKey(), entry.getPrivateKey()); + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) + private static void generateKeyPair(Context context, String alias) + throws GeneralSecurityException { + final Calendar start = new GregorianCalendar(); + final Calendar end = new GregorianCalendar(); + end.add(Calendar.YEAR, 100); + + final KeyPairGeneratorSpec spec = new KeyPairGeneratorSpec.Builder(context) + .setAlias(alias) + .setSubject(new X500Principal("CN=" + alias)) + .setSerialNumber(BigInteger.ONE) + .setStartDate(start.getTime()) + .setEndDate(end.getTime()) + .build(); + + final KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA", "AndroidKeyStore"); + + gen.initialize(spec); + gen.generateKeyPair(); + } + + /** + * Wrap a {@link SecretKey} using the public key assigned to this wrapper. + * Use {@link #unwrap(byte[])} to later recover the original + * {@link SecretKey}. + * + * @return a wrapped version of the given {@link SecretKey} that can be + * safely stored on untrusted storage. + */ + public byte[] wrap(SecretKey key) throws GeneralSecurityException { + mCipher.init(Cipher.WRAP_MODE, mPair.getPublic()); + return mCipher.wrap(key); + } + + /** + * Unwrap a {@link SecretKey} using the private key assigned to this + * wrapper. + * + * @param blob a wrapped {@link SecretKey} as previously returned by + * {@link #wrap(SecretKey)}. + */ + public SecretKey unwrap(byte[] blob) throws GeneralSecurityException { + mCipher.init(Cipher.UNWRAP_MODE, mPair.getPrivate()); + + return (SecretKey) mCipher.unwrap(blob, "AES", Cipher.SECRET_KEY); + } +} \ No newline at end of file diff --git a/app/src/main/java/net/bierbaumer/otp_authenticator/SettingsHelper.java b/app/src/main/java/net/bierbaumer/otp_authenticator/SettingsHelper.java new file mode 100644 index 00000000..61ce8aab --- /dev/null +++ b/app/src/main/java/net/bierbaumer/otp_authenticator/SettingsHelper.java @@ -0,0 +1,68 @@ +package net.bierbaumer.otp_authenticator; + +import android.content.Context; +import android.os.Build; + +import org.json.JSONArray; +import org.json.JSONException; + +import java.io.File; +import java.util.ArrayList; + +import javax.crypto.SecretKey; + +import static net.bierbaumer.otp_authenticator.Utils.readFully; +import static net.bierbaumer.otp_authenticator.Utils.writeFully; + +public class SettingsHelper { + public static final String KEY_FILE = "otp.key"; + public static final String SETTINGS_FILE = "secrets.dat"; + + public static void store(Context context, ArrayList entries){ + JSONArray a = new JSONArray(); + + for(Entry e: entries){ + try { + a.put(e.toJSON()); + } catch (JSONException e1) { + } + } + + try { + byte[] data = a.toString().getBytes(); + + int currentApiVersion = android.os.Build.VERSION.SDK_INT; + if (currentApiVersion >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + SecretKey key = EncryptionHelper.loadOrGenerateKeys(context, new File(context.getFilesDir() + "/" + KEY_FILE)); + data = EncryptionHelper.encrypt(key,data); + } + + writeFully(new File(context.getFilesDir() + "/" + SETTINGS_FILE), data); + + } catch (Exception e) { + } + + } + + public static ArrayList load(Context context){ + ArrayList entries = new ArrayList<>(); + + try { + byte[] data = readFully(new File(context.getFilesDir() + "/" + SETTINGS_FILE)); + + int currentApiVersion = android.os.Build.VERSION.SDK_INT; + if (currentApiVersion >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + SecretKey key = EncryptionHelper.loadOrGenerateKeys(context, new File(context.getFilesDir() + "/" + KEY_FILE)); + data = EncryptionHelper.decrypt(key, data); + } + JSONArray a = new JSONArray(new String(data)); + + for(int i=0;i< a.length(); i++){ + entries.add(new Entry(a.getJSONObject(i) )); + } + } + catch (Exception e) { + } + return entries; + } +} \ No newline at end of file diff --git a/app/src/main/java/net/bierbaumer/otp_authenticator/TOTPHelper.java b/app/src/main/java/net/bierbaumer/otp_authenticator/TOTPHelper.java new file mode 100644 index 00000000..ced4b8d8 --- /dev/null +++ b/app/src/main/java/net/bierbaumer/otp_authenticator/TOTPHelper.java @@ -0,0 +1,52 @@ +package net.bierbaumer.otp_authenticator; + +import org.apache.commons.codec.binary.Base32; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + + +public class TOTPHelper { + public static final String SHA1 = "HmacSHA1"; + + public static String generate(byte[] secret) { + return String.format("%06d", generate(secret, System.currentTimeMillis() / 1000, 6)); + } + + public static int generate(byte[] key, long t, int digits) + { + int r = 0; + try { + t /= 30; + byte[] data = new byte[8]; + long value = t; + for (int i = 8; i-- > 0; value >>>= 8) { + data[i] = (byte) value; + } + + SecretKeySpec signKey = new SecretKeySpec(key, SHA1); + Mac mac = Mac.getInstance(SHA1); + mac.init(signKey); + byte[] hash = mac.doFinal(data); + + + int offset = hash[20 - 1] & 0xF; + + long truncatedHash = 0; + for (int i = 0; i < 4; ++i) { + truncatedHash <<= 8; + truncatedHash |= (hash[offset + i] & 0xFF); + } + + truncatedHash &= 0x7FFFFFFF; + truncatedHash %= Math.pow(10,digits); + + r = (int) truncatedHash; + } + + catch(Exception e){ + } + + return r; + } +} diff --git a/app/src/main/java/net/bierbaumer/otp_authenticator/Utils.java b/app/src/main/java/net/bierbaumer/otp_authenticator/Utils.java new file mode 100644 index 00000000..45c83ce3 --- /dev/null +++ b/app/src/main/java/net/bierbaumer/otp_authenticator/Utils.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2013 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. + */ + +package net.bierbaumer.otp_authenticator; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +public class Utils { + public static void writeFully(File file, byte[] data) throws IOException { + final OutputStream out = new FileOutputStream(file); + try { + out.write(data); + } finally { + out.close(); + } + } + + public static byte[] readFully(File file) throws IOException { + final InputStream in = new FileInputStream(file); + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int count; + while ((count = in.read(buffer)) != -1) { + bytes.write(buffer, 0, count); + } + return bytes.toByteArray(); + } finally { + in.close(); + } + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..b5c8ee55 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml new file mode 100644 index 00000000..6a9561d6 --- /dev/null +++ b/app/src/main/res/layout/content_main.xml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_about.xml b/app/src/main/res/layout/dialog_about.xml new file mode 100644 index 00000000..bd0249aa --- /dev/null +++ b/app/src/main/res/layout/dialog_about.xml @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/row.xml b/app/src/main/res/layout/row.xml new file mode 100644 index 00000000..e7d8fd98 --- /dev/null +++ b/app/src/main/res/layout/row.xml @@ -0,0 +1,29 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_edit.xml b/app/src/main/res/menu/menu_edit.xml new file mode 100644 index 00000000..2295af22 --- /dev/null +++ b/app/src/main/res/menu/menu_edit.xml @@ -0,0 +1,18 @@ +

+ + + + diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml new file mode 100644 index 00000000..a8d164c2 --- /dev/null +++ b/app/src/main/res/menu/menu_main.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..cd604e41 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..5b9945bb Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..2fc00613 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..c8441026 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..ae4cb590 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/raw/about.html b/app/src/main/res/raw/about.html new file mode 100644 index 00000000..008d8565 --- /dev/null +++ b/app/src/main/res/raw/about.html @@ -0,0 +1,27 @@ + + + + + + + +
+ +

OTP Authenticator 1.0

+

© 2015 - Bruno Bierbaumer

+

Project Homepage

+

Acknowledgments

+
+ + diff --git a/app/src/main/res/raw/opensource.html b/app/src/main/res/raw/opensource.html new file mode 100644 index 00000000..ceaa0838 --- /dev/null +++ b/app/src/main/res/raw/opensource.html @@ -0,0 +1,31 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-v21/styles.xml b/app/src/main/res/values-v21/styles.xml new file mode 100644 index 00000000..51ada8a7 --- /dev/null +++ b/app/src/main/res/values-v21/styles.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/values-w820dp/dimens.xml b/app/src/main/res/values-w820dp/dimens.xml new file mode 100644 index 00000000..63fc8164 --- /dev/null +++ b/app/src/main/res/values-w820dp/dimens.xml @@ -0,0 +1,6 @@ + + + 64dp + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..50c6e04f --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + +#607D8B +#455A64 +#CFD8DC +#FFC107 +#212121 +#727272 +#FFFFFF +#B6B6B6 + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..812cb7be --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,6 @@ + + + 16dp + 16dp + 16dp + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..38b6b3fc --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + OTP Authenticator + Authenticator + Settings + Scan QR-Code + No Account has been added yet + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..04fa64e2 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,19 @@ + + + + + + + +