├── .gitignore ├── .idea ├── .gitignore ├── compiler.xml ├── jarRepositories.xml ├── misc.xml ├── runConfigurations.xml └── vcs.xml ├── .travis.yml ├── LICENSE ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── java │ └── com │ │ └── codingchili │ │ └── mouse │ │ └── enigma │ │ ├── MainActivity.kt │ │ ├── autofill │ │ └── AutoService.kt │ │ ├── model │ │ ├── AuditLogger.kt │ │ ├── Credential.kt │ │ ├── CredentialBank.kt │ │ ├── FaviconLoader.kt │ │ ├── MousePreferences.kt │ │ ├── Performance.kt │ │ ├── PwnedChecker.kt │ │ ├── PwnedSite.kt │ │ └── Vault.kt │ │ └── presenter │ │ ├── AddCredentialFragment.kt │ │ ├── ApplicationInfoFragment.kt │ │ ├── ApplicationSettingsFragment.kt │ │ ├── BottomNavigationDrawerFragment.kt │ │ ├── CredentialInfoFragment.kt │ │ ├── CredentialListFragment.kt │ │ ├── DialogDelayedPositiveButton.kt │ │ ├── ExportCredentialFragment.kt │ │ ├── FragmentSelector.kt │ │ ├── ImportCredentialFragment.kt │ │ └── MasterSetupFragment.kt │ └── res │ ├── drawable-v24 │ └── ic_launcher_foreground.xml │ ├── drawable │ ├── add_icon.png │ ├── add_icon_simple.png │ ├── baseline_bug_report_24.xml │ ├── baseline_clear_24.xml │ ├── baseline_delete_outline_24.xml │ ├── baseline_feedback_24.xml │ ├── baseline_file_copy_24.xml │ ├── baseline_fingerprint_24.xml │ ├── baseline_grade_24.xml │ ├── baseline_import_export_24.xml │ ├── baseline_info_24.xml │ ├── baseline_launch_24.xml │ ├── baseline_menu_24.xml │ ├── baseline_open_in_browser_24.xml │ ├── baseline_remove_red_eye_24.xml │ ├── baseline_security_24.xml │ ├── baseline_settings_black_24.png │ ├── baseline_sync_24.xml │ ├── baseline_warning_24.xml │ ├── gradient.xml │ ├── ic_baseline_star_rate_18.xml │ ├── ic_launcher_background.xml │ ├── mouse.png │ └── octocat_smaller.png │ ├── layout │ ├── activity_main.xml │ ├── fragment_add_credential.xml │ ├── fragment_application_info.xml │ ├── fragment_application_settings.xml │ ├── fragment_bottomsheet.xml │ ├── fragment_credential_info.xml │ ├── fragment_credential_list.xml │ ├── fragment_import.xml │ ├── fragment_master_setup.xml │ ├── list_domain_pwn.xml │ └── list_item_credential.xml │ ├── menu │ ├── credential_info.xml │ ├── credential_list.xml │ └── navigation.xml │ ├── mipmap-anydpi-v26 │ ├── ic_launcher.xml │ └── ic_launcher_round.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_round.png │ ├── values-cn │ └── strings.xml │ ├── values-fr │ └── strings.xml │ ├── values-ru │ └── strings.xml │ ├── values-sv │ └── strings.xml │ ├── values │ ├── colors.xml │ ├── strings.xml │ ├── styles.xml │ └── version.xml │ └── xml │ └── autofill.xml ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── licenses └── android-sdk-license ├── preview.jpg └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # Files for the ART/Dalvik VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # Generated files 12 | bin/ 13 | gen/ 14 | out/ 15 | 16 | # Gradle files 17 | .gradle/ 18 | build/ 19 | 20 | # Local configuration file (sdk path, etc) 21 | local.properties 22 | 23 | # Proguard folder generated by Eclipse 24 | proguard/ 25 | 26 | # Log Files 27 | *.log 28 | 29 | # Android Studio Navigation editor temp files 30 | .navigation/ 31 | 32 | # Android Studio captures folder 33 | captures/ 34 | 35 | # IntelliJ 36 | *.iml 37 | .idea/workspace.xml 38 | .idea/tasks.xml 39 | .idea/gradle.xml 40 | .idea/assetWizardSettings.xml 41 | .idea/dictionaries 42 | .idea/libraries 43 | .idea/caches 44 | 45 | # Keystore files 46 | # Uncomment the following line if you do not want to check your keystore files in. 47 | #*.jks 48 | 49 | # External native build folder generated in Android Studio 2.2 and later 50 | .externalNativeBuild 51 | 52 | # Google Services (e.g. APIs or Firebase) 53 | google-services.json 54 | 55 | # Freeline 56 | freeline.py 57 | freeline/ 58 | freeline_project_description.json 59 | 60 | # fastlane 61 | fastlane/report.xml 62 | fastlane/Preview.html 63 | fastlane/screenshots 64 | fastlane/test_output 65 | fastlane/readme.md 66 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 29 | 30 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: android 2 | script: 3 | - ./gradlew build --stacktrace 4 | android: 5 | components: 6 | - tools 7 | - platform-tools 8 | - build-tools-28.0.3 9 | - android-28 10 | - extra-google-m2repository 11 | - extra-android-m2repository 12 | - extra-android-support 13 | licenses: 14 | - android-sdk-preview-license-.+ 15 | - android-sdk-license-.+ 16 | - google-gdk-license-.+ 17 | before_install: 18 | - mkdir "$ANDROID_HOME/licenses" || true 19 | - cp ./licenses/* "$ANDROID_HOME/licenses/" 20 | before_cache: 21 | - rm -f $HOME/.gradle/caches/modules-2/modules-2.lock 22 | - rm -fr $HOME/.gradle/caches/*/plugin-resolution/ 23 | cache: 24 | directories: 25 | - $HOME/.gradle/caches/ 26 | - $HOME/.gradle/wrapper/ 27 | - $HOME/.android/build-cache 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Robin Duda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Enigmatic Mouse [![Build Status](https://app.travis-ci.com/codingchili/enigmatic-mouse.svg?branch=master)](https://app.travis-ci.com/github/codingchili/enigmatic-mouse) 2 | The enigmatic mouse will keep your passwords safe - password manager on Android written in Kotlin. 3 | 4 | View the [YouTube Demo](https://www.youtube.com/watch?v=CcIvlFmBL5w). 5 | 6 | ![mouse enigma preview](https://raw.githubusercontent.com/codingchili/enigmatic-mouse/master/preview.jpg "Current snapshot version") 7 | 8 | Password manager in 1500 lines of KOTLIN! 9 | 10 | The Enigmatic Mouse is a small password manager, the purpose is to be as small as possible 11 | while still providing a bearable user experience. By being small The Mouse is auditable by 12 | our user base. No need to trust a third party with the keys to the kingdom, you can fork 13 | the repository and add new features or even disable existing ones! For maximum security 14 | we recommend that you build and side-load the application yourself. This ensures that 15 | a rogue version published to the Play store won't steal all your passwords. 16 | 17 | Requires SDK26 (can probably be built with lower API levels too.) 18 | 19 | # Features 20 | - application is protected by fingerprint authentication. 21 | - securely store passwords encrypted within Realm. 22 | - shows icons for the sites you add from the internet. 23 | - allows you to copy to clipboard or view passwords within the app. 24 | - set a credential as favorite and sticky it to the top of the list. 25 | - downloads the haveibeenpwned domain list and compares with your accounts. 26 | 27 | # Security 28 | The encryption scheme 29 | 30 | The master password is combined with a key derivation function (Scrypt) to generate an AES key of 256 bits. 31 | Another key is then created within the Trusty TEE (HSM) and used to encrypt the AES key. 32 | The key stored in TEE is protected by your fingerprint and never leaves the HSM. 33 | We store the encrypted key, the salt used with the master password and the 34 | initialization vector used as shared preferences. This information is not a 35 | cryptographic secret. When the user authenticates with their fingerprint, we use the 36 | AES key stored in the HSM to decrypt the key derived from the master password. When the 37 | master key is recovered, we initialize the Realm encrypted database with it. 38 | 39 | ### Features 40 | - Fingerprint authentication 41 | - Scrypt, N=65536, r=8, p=1 42 | - Realm - encrypted with AES256 key. 43 | - AES256-CBC-PKCS7 44 | - Trusty TEE 45 | 46 | ### Permissions 47 | 48 | The following permissions are required by the application and enabled by default in `AndroidManifest.xml`. 49 | ```xml 50 | 51 | 52 | ``` 53 | 54 | The biometric permissions is used to authenticate with the Trusty TEE (HSM) 55 | using a fingerprint. In newer versions of Android there might be more ways 56 | to authenticate with biometrics. 57 | 58 | The Internet permission is used to download icons from websites. For example 59 | if you add a credential for youtube.com -> we will fetch the index page from 60 | youtube and parse any ` Make Project. 67 | 68 | Without Android studio, 69 | ```console 70 | ./gradlew build 71 | ``` 72 | 73 | Find the unsigned .apk in ```app\build\outputs\apk\release```. 74 | 75 | # Installing 76 | 77 | Installing the application yourself is the recommended way, as it removes the middleman. 78 | 79 | ##### Side-loading (Android studio) 80 | - Open the project with android studio -> run -> select your device 81 | 82 | This will build the APK and install it onto your device. 83 | 84 | ##### Side-loading (APK file) 85 | Follow the instructions for building an unsigned APK and then copy the .apk to your device. Alternatively download 86 | a signed APK from the releases. 87 | 88 | 1. Enable installation from untrusted sources 89 | 2. open the file to install the APK 90 | 3. Disable installation from untrusted sources 91 | 92 | ##### Google Play store 93 | Now published on the play store! 94 | 95 | [![Enigmatic Mouse @ Play Store](https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png)](https://play.google.com/store/apps/details?id=com.codingchili.mouse.enigma) 96 | 97 | # Contributing 98 | Contributions are welcome! We encourage you to look through the available issues, 99 | create new or comment on existing. All ideas are welcome and well needed. 100 | 101 | Code reviews and security audits are also very welcome. 102 | 103 | [![donate](https://img.shields.io/badge/donate-%CE%9ETH%20/%20%C9%83TC-ff00cc.svg?style=flat&logo=ethereum)](https://commerce.coinbase.com/checkout/673e693e-be6d-4583-9791-611da87861e3) 104 | 105 | # Resources 106 | During development the following talk has been very helpful in implementing the security scheme. 107 | 108 | Ben Oberkfell - Advanced Android Fingerprint Security | Øredev 2017 109 | [https://vimeo.com/243345710](https://vimeo.com/243345710) 110 | 111 | [benoberkfell/CryptoDiary](https://github.com/benoberkfell/CryptoDiary) 112 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | id 'kotlin-kapt' 5 | id 'realm-android' 6 | } 7 | 8 | android { 9 | compileSdkVersion 31 10 | defaultConfig { 11 | applicationId "com.codingchili.mouse.enigma" 12 | minSdkVersion 26 13 | targetSdkVersion 31 14 | multiDexEnabled true 15 | versionCode 5 16 | versionName "1.2.2" 17 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" 18 | } 19 | buildTypes { 20 | release { 21 | minifyEnabled false 22 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' 23 | } 24 | } 25 | } 26 | 27 | configurations.all { 28 | resolutionStrategy.eachDependency { DependencyResolveDetails details -> 29 | def requested = details.requested 30 | if (requested.group == "com.android.support") { 31 | if (!requested.name.startsWith("multidex")) { 32 | details.useVersion "26.+" 33 | } 34 | } 35 | } 36 | } 37 | 38 | dependencies { 39 | implementation fileTree(dir: 'libs', include: ['*.jar']) 40 | implementation 'com.madgag.spongycastle:core:1.58.0.0' 41 | implementation 'com.madgag.spongycastle:prov:1.58.0.0' 42 | 43 | implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" 44 | implementation 'androidx.appcompat:appcompat:1.3.1' 45 | implementation 'androidx.constraintlayout:constraintlayout:2.1.1' 46 | implementation 'androidx.coordinatorlayout:coordinatorlayout:1.1.0' 47 | implementation 'androidx.biometric:biometric:1.2.0-alpha03' 48 | implementation 'com.jakewharton:disklrucache:2.0.2' 49 | implementation 'org.jsoup:jsoup:1.14.3' 50 | 51 | implementation 'com.google.android.material:material:1.5.0-alpha04' 52 | implementation 'com.loopj.android:android-async-http:1.4.11' 53 | 54 | implementation 'com.google.zxing:core:3.4.1' 55 | implementation 'com.google.zxing:android-core:3.3.0' 56 | 57 | 58 | testImplementation 'junit:junit:4.13.2' 59 | } 60 | repositories { 61 | mavenCentral() 62 | google() 63 | } 64 | 65 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 20 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingchili/mouse/enigma/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.codingchili.mouse.enigma 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import com.codingchili.mouse.enigma.model.MousePreferences 6 | import com.codingchili.mouse.enigma.presenter.FragmentSelector 7 | import java.security.Security 8 | 9 | 10 | /** 11 | * The Main activity. 12 | */ 13 | class MainActivity : AppCompatActivity() { 14 | private lateinit var preferences: MousePreferences 15 | private var resumed = false 16 | 17 | init { 18 | Security.insertProviderAt(org.spongycastle.jce.provider.BouncyCastleProvider(), 1) 19 | } 20 | 21 | override fun onCreate(savedInstanceState: Bundle?) { 22 | super.onCreate(savedInstanceState) 23 | 24 | setContentView(R.layout.activity_main) 25 | setSupportActionBar(findViewById(R.id.bottom_app_bar)) 26 | 27 | FragmentSelector.init(this) 28 | 29 | preferences = MousePreferences(application) 30 | } 31 | 32 | override fun onResume() { 33 | if (!resumed || (resumed && preferences.lockOnresume())) { 34 | FragmentSelector.master() 35 | } 36 | super.onResume() 37 | } 38 | 39 | override fun onPause() { 40 | resumed = true 41 | super.onPause() 42 | } 43 | 44 | override fun onBackPressed() { 45 | if (supportFragmentManager.backStackEntryCount > 1) { 46 | supportFragmentManager.popBackStack() 47 | } else { 48 | finish() 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingchili/mouse/enigma/autofill/AutoService.kt: -------------------------------------------------------------------------------- 1 | package com.codingchili.mouse.enigma.autofill 2 | 3 | import android.R 4 | import android.app.assist.AssistStructure 5 | import android.os.CancellationSignal 6 | import android.os.Parcel 7 | import android.service.autofill.* 8 | import android.util.Log 9 | import android.view.autofill.AutofillId 10 | import android.view.autofill.AutofillValue 11 | import android.widget.RemoteViews 12 | 13 | 14 | 15 | /** 16 | * Autofill service: work in progress. 17 | */ 18 | class AutoService: AutofillService() { 19 | 20 | override fun onFillRequest( 21 | request: FillRequest, 22 | cancellationSignal: CancellationSignal, 23 | callback: FillCallback 24 | ) { 25 | Log.w("autofill", "DING DING FILLREQUEST") 26 | 27 | // Get the structure from the request 28 | val contexts: List = request.fillContexts 29 | val structure: AssistStructure = contexts[contexts.size - 1].structure 30 | 31 | contexts.forEach { context -> 32 | traverseStructure(context.structure) 33 | } 34 | 35 | // Traverse the structure looking for nodes to fill out. 36 | val parsedStructure: ParsedStructure = parseStructure(structure) 37 | 38 | // Fetch user data that matches the fields. 39 | val (username: String, password: String) = fetchUserData(parsedStructure) 40 | 41 | // Build the presentation of the datasets 42 | val usernamePresentation = RemoteViews(packageName, R.layout.simple_list_item_1) 43 | usernamePresentation.setTextViewText(android.R.id.text1, username) 44 | 45 | val passwordPresentation = RemoteViews(packageName, android.R.layout.simple_list_item_1) 46 | passwordPresentation.setTextViewText(android.R.id.text1, password) 47 | 48 | // Add a dataset to the response 49 | val fillResponse: FillResponse = FillResponse.Builder() 50 | .addDataset(Dataset.Builder() 51 | .setValue( 52 | parsedStructure.usernameId, 53 | AutofillValue.forText(username), 54 | usernamePresentation 55 | ) 56 | .setValue( 57 | parsedStructure.passwordId, 58 | AutofillValue.forText(password), 59 | passwordPresentation 60 | ) 61 | .build()) 62 | .build() 63 | 64 | // If there are no errors, call onSuccess() and pass the response 65 | callback.onSuccess(fillResponse) 66 | } 67 | 68 | fun traverseStructure(structure: AssistStructure) { 69 | val windowNodes: List = 70 | structure.run { 71 | (0 until windowNodeCount).map { getWindowNodeAt(it) } 72 | } 73 | 74 | windowNodes.forEach { windowNode: AssistStructure.WindowNode -> 75 | val viewNode: AssistStructure.ViewNode? = windowNode.rootViewNode 76 | traverseNode(viewNode) 77 | } 78 | } 79 | 80 | fun traverseNode(viewNode: AssistStructure.ViewNode?) { 81 | if (viewNode?.autofillHints?.isNotEmpty() == true) { 82 | Log.w("autofill", viewNode.autofillHints!!.joinToString(separator = ", ")) 83 | // If the client app provides autofill hints, you can obtain them using: 84 | // viewNode.getAutofillHints(); 85 | } else { 86 | Log.w("autofill", "${viewNode?.hint} - ${viewNode?.text?.toString()} - ${viewNode?.webDomain} - ${viewNode?.autofillType} - " + 87 | "${viewNode?.autofillId} - ${viewNode?.autofillValue} - ${viewNode?.autofillOptions?.joinToString(separator = "")} - " + 88 | "${viewNode?.className} - ${viewNode?.idType} - ${viewNode?.htmlInfo?.tag} - ${viewNode?.htmlInfo?.attributes?.joinToString(separator = ",")}") 89 | // Or use your own heuristics to describe the contents of a view 90 | // using methods such as getText() or getHint(). 91 | 92 | 93 | } 94 | 95 | val children: List? = 96 | viewNode?.run { 97 | (0 until childCount).map { getChildAt(it) } 98 | } 99 | 100 | children?.forEach { childNode: AssistStructure.ViewNode -> 101 | traverseNode(childNode) 102 | } 103 | } 104 | 105 | private fun fetchUserData(parsedStructure: ParsedStructure): UserData { 106 | return UserData("robin", "testing") 107 | } 108 | 109 | fun parseStructure(structure: AssistStructure): ParsedStructure { 110 | return ParsedStructure( 111 | AutofillId.CREATOR.createFromParcel(Parcel.obtain()), 112 | AutofillId.CREATOR.createFromParcel(Parcel.obtain()) 113 | ) 114 | } 115 | 116 | private fun parseWebDomain(viewNode: AssistStructure.ViewNode, validWebDomain: StringBuilder) { 117 | val webDomain = viewNode.webDomain 118 | if (webDomain != null) { 119 | Log.w("child web domain: %s", webDomain) 120 | if (validWebDomain.length > 0) { 121 | if (webDomain != validWebDomain.toString()) { 122 | throw SecurityException("Found multiple web domains: valid= " 123 | + validWebDomain + ", child=" + webDomain) 124 | } 125 | } else { 126 | validWebDomain.append(webDomain) 127 | } 128 | } 129 | } 130 | 131 | private fun parseNode(root: AssistStructure.ViewNode, allHints: MutableList, 132 | autofillSaveType: Int, autofillIds: MutableList, 133 | focusedAutofillIds: MutableList) { 134 | /* val hints = root.autofillHints 135 | if (hints != null) { 136 | for (hint in hints) { 137 | val fieldTypeWithHints = mFieldTypesByAutofillHint.get(hint) 138 | if (fieldTypeWithHints != null && fieldTypeWithHints!!.fieldType != null) { 139 | allHints.add(hint) 140 | autofillSaveType.value = autofillSaveType.value or fieldTypeWithHints!!.fieldType.getSaveInfo() 141 | autofillIds.add(root.autofillId!!) 142 | } 143 | } 144 | } 145 | if (root.isFocused) { 146 | focusedAutofillIds.add(root.autofillId!!) 147 | }*/ 148 | } 149 | 150 | override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { 151 | callback.onSuccess() 152 | } 153 | 154 | data class ParsedStructure(var usernameId: AutofillId, var passwordId: AutofillId) 155 | 156 | data class UserData(var username: String, var password: String) 157 | 158 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codingchili/mouse/enigma/model/AuditLogger.kt: -------------------------------------------------------------------------------- 1 | package com.codingchili.mouse.enigma.model 2 | 3 | import android.content.Context 4 | import com.codingchili.mouse.enigma.R 5 | import com.codingchili.mouse.enigma.model.CredentialBank.log 6 | 7 | /** 8 | * Utility class to handle audit logging. 9 | */ 10 | object AuditLogger { 11 | 12 | fun onFingerprintAuthenticated(context: Context) { 13 | log(context.getString(R.string.audit_fingerprint_authenticated)) 14 | } 15 | 16 | fun onPasswordAuthenticate(context: Context) { 17 | log(context.getString(R.string.audit_password_authenticated)) 18 | } 19 | 20 | fun onPwnedListUpdated(context: Context) { 21 | log(context.getString(R.string.audit_security_list_update)) 22 | } 23 | 24 | fun onPasswordDisplayed(context: Context, credential: Credential) { 25 | log(context.getString(R.string.audit_displayed_password) 26 | .format(credential.username, credential.domain)) 27 | } 28 | 29 | fun onCopiedToClipboard(context: Context, credential: Credential) { 30 | log(context.getString(R.string.audit_coped_password) 31 | .format(credential.username, credential.domain)) 32 | } 33 | 34 | fun onAddedCredential(context: Context, credential: Credential) { 35 | log(context.getString(R.string.audit_added_credential) 36 | .format(credential.username, credential.domain)) 37 | } 38 | 39 | fun onRemovedCredential(context: Context, credential: Credential) { 40 | log(context.getString(R.string.audit_removed_credential) 41 | .format(credential.username, credential.domain)) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingchili/mouse/enigma/model/Credential.kt: -------------------------------------------------------------------------------- 1 | package com.codingchili.mouse.enigma.model 2 | 3 | import io.realm.RealmObject 4 | import io.realm.annotations.PrimaryKey 5 | import java.time.ZonedDateTime 6 | import java.time.format.DateTimeFormatter 7 | import java.util.* 8 | 9 | /** 10 | * Data model for Credential information. 11 | */ 12 | const val ID_FIELD = "id" 13 | 14 | open class Credential(): RealmObject() { 15 | 16 | constructor(domain: String, username: String, password: String) : this() { 17 | this.domain = domain 18 | this.username = username 19 | this.password = password 20 | } 21 | 22 | @PrimaryKey 23 | var id: String = UUID.randomUUID().toString() 24 | var created : String = ZonedDateTime.now().format(DateTimeFormatter.ISO_DATE) 25 | var domain: String = "" 26 | var username: String = "" 27 | var password: String = "" 28 | var favorite: Boolean = false 29 | 30 | override fun equals(other: Any?): Boolean { 31 | return other != null && id == (other as Credential).id 32 | } 33 | 34 | override fun hashCode(): Int { 35 | return id.hashCode() 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codingchili/mouse/enigma/model/CredentialBank.kt: -------------------------------------------------------------------------------- 1 | package com.codingchili.mouse.enigma.model 2 | 3 | import android.security.keystore.KeyGenParameterSpec 4 | import android.security.keystore.KeyProperties 5 | import android.util.Log 6 | import io.realm.Realm 7 | import io.realm.RealmConfiguration 8 | import org.spongycastle.crypto.generators.SCrypt 9 | import java.security.KeyStore 10 | import java.security.SecureRandom 11 | import java.time.ZonedDateTime 12 | import java.time.format.DateTimeFormatter 13 | import java.time.temporal.ChronoUnit 14 | import javax.crypto.Cipher 15 | import javax.crypto.KeyGenerator 16 | import javax.crypto.spec.IvParameterSpec 17 | import javax.crypto.spec.SecretKeySpec 18 | 19 | 20 | /** 21 | * Manages the secure storage of credentials. 22 | */ 23 | object CredentialBank { 24 | private const val KEY_NAME = "bank_mouse" 25 | private const val KEYSTORE = "AndroidKeyStore" 26 | private const val ITERATIONS = 65536 27 | private const val MAX_LOG_BUFFER = 256 28 | private const val SALT_BYTES = 32 29 | private const val KDF_OUTPUT_BYTES = 64 30 | private const val REALM_SCHEMA_VERSION = 14L 31 | private const val REALM_NAME = "credentials_$REALM_SCHEMA_VERSION" // skip migration support for now. 32 | 33 | private val keyGenerator: KeyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) 34 | private val keyStore: KeyStore = KeyStore.getInstance(KEYSTORE) 35 | private val listeners = ArrayList<() -> Unit>() 36 | private val random = SecureRandom() 37 | private var cache: MutableList = ArrayList() 38 | private var vault: Vault = Vault() 39 | 40 | private lateinit var cipher: Cipher 41 | private lateinit var preferences: MousePreferences 42 | private lateinit var key: ByteArray 43 | 44 | fun initCipher(encrypt: Boolean) { 45 | cipher = Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + "/" 46 | + KeyProperties.BLOCK_MODE_CBC + "/" 47 | + KeyProperties.ENCRYPTION_PADDING_PKCS7, "AndroidKeyStoreBCWorkaround") 48 | 49 | if (encrypt) { 50 | generateTEEKey() 51 | } 52 | 53 | keyStore.load(null) 54 | 55 | // key cannot be used until after authentication. 56 | val secretKey = keyStore.getKey(KEY_NAME, null) 57 | 58 | if (encrypt) { 59 | cipher.init(Cipher.ENCRYPT_MODE, secretKey) 60 | } else { 61 | cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(preferences.getTeeIv())) 62 | } 63 | } 64 | 65 | private fun generateSalt(): ByteArray { 66 | val salt = ByteArray(SALT_BYTES) 67 | random.nextBytes(salt) 68 | return salt 69 | } 70 | 71 | fun generateTEEKey() { 72 | val keyGenParameterSpec = KeyGenParameterSpec.Builder(KEY_NAME, 73 | KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) 74 | .setBlockModes(KeyProperties.BLOCK_MODE_CBC) 75 | .setUserAuthenticationRequired(true) 76 | .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) 77 | .setRandomizedEncryptionRequired(false) 78 | .build() 79 | 80 | keyGenerator.init(keyGenParameterSpec) 81 | keyGenerator.generateKey() 82 | } 83 | 84 | private fun generateKDFKey(secret: ByteArray, salt: ByteArray): ByteArray { 85 | var bytes = ByteArray(0) 86 | 87 | Performance("CredentialBank:generateKey").sync({ 88 | bytes = SCrypt.generate(secret, salt, ITERATIONS, 8, 1, KDF_OUTPUT_BYTES) 89 | }) 90 | return bytes 91 | } 92 | 93 | fun store(credential: Credential) { 94 | cache.remove(credential) 95 | cache.add(credential) 96 | sortCache() 97 | onCacheUpdated() 98 | 99 | vault.credentials.remove(credential) 100 | vault.credentials.add(credential) 101 | save() 102 | } 103 | 104 | fun auditLog(): List { 105 | return vault.log 106 | } 107 | 108 | fun log(line: String) { 109 | val timestamp: String = ZonedDateTime.now() 110 | .truncatedTo(ChronoUnit.SECONDS) 111 | .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) 112 | 113 | vault.log.add(0, "$timestamp: $line") 114 | 115 | if (vault.log.size > MAX_LOG_BUFFER) { 116 | vault.log.removeAt(vault.log.size - 1) 117 | } 118 | 119 | save() 120 | } 121 | 122 | fun retrieve(): List { 123 | return cache 124 | } 125 | 126 | fun remove(credential: Credential) { 127 | cache.remove(credential) 128 | onCacheUpdated() 129 | 130 | Realm.getDefaultInstance().use { 131 | it.executeTransactionAsync { realm -> 132 | realm.where(credential.javaClass).equalTo(ID_FIELD, credential.id) 133 | .findAll() 134 | .deleteAllFromRealm() 135 | } 136 | } 137 | } 138 | 139 | private fun sortCache() { 140 | cache = cache.asSequence() 141 | .sortedWith(compareBy({ !it.favorite }, { it.domain })) 142 | .toMutableList() as ArrayList 143 | } 144 | 145 | fun onChangeListener(callback: () -> Unit) { 146 | listeners.add(callback) 147 | } 148 | 149 | fun onCacheUpdated() { 150 | listeners.forEach { callback -> 151 | callback.invoke() 152 | } 153 | } 154 | 155 | fun setPreferences(preferences: MousePreferences) { 156 | this.preferences = preferences 157 | } 158 | 159 | /** 160 | * Connect to the realm instance - requires calling any of the install 161 | * or unlock methods first. Must be called from the UI thread. 162 | */ 163 | fun connect(): Boolean { 164 | Realm.setDefaultConfiguration(RealmConfiguration.Builder() 165 | .encryptionKey(key) 166 | .schemaVersion(REALM_SCHEMA_VERSION) 167 | .name(REALM_NAME) 168 | .build()) 169 | try { 170 | cache.clear() 171 | 172 | Realm.getDefaultInstance().use { 173 | val found = it.where(Vault::class.java) 174 | .equalTo( 175 | NAME_FIELD, 176 | DEFAULT_NAME) 177 | .findFirst() 178 | 179 | if (found == null) { 180 | vault = Vault() 181 | } else { 182 | this.vault = it.copyFromRealm(found) 183 | } 184 | 185 | cache.addAll(vault.credentials) 186 | } 187 | sortCache() 188 | return true 189 | } catch (e: Exception) { 190 | Log.wtf(javaClass.name, e) 191 | return false 192 | } 193 | } 194 | 195 | fun installWithFingerprint(password: String) { 196 | installWithPassword(password) 197 | 198 | val spec = SecretKeySpec(key, "AES") 199 | val encryptedKey = cipher.doFinal(spec.encoded) 200 | 201 | preferences.setEncryptedMaster(encryptedKey) 202 | .setTeeIV(cipher.iv) 203 | .setFPSupported(true) 204 | } 205 | 206 | fun installWithPassword(password: String) { 207 | val salt = generateSalt() 208 | key = generateKDFKey(password.toByteArray(), salt) 209 | 210 | preferences.setMasterSalt(salt) 211 | .setFPSupported(false) 212 | .setInstalled() 213 | } 214 | 215 | fun unlockWithFingerprint() { 216 | key = cipher.doFinal(preferences.getEncryptedMaster()) 217 | } 218 | 219 | fun unlockWithPassword(password: String) { 220 | key = generateKDFKey(password.toByteArray(), preferences.getMasterSalt()) 221 | } 222 | 223 | fun getCipher(): Cipher { 224 | return cipher 225 | } 226 | 227 | fun uninstall() { 228 | preferences.reset() 229 | try { 230 | if (!Realm.deleteRealm(RealmConfiguration.Builder().name(REALM_NAME).build())) { 231 | Log.w(javaClass.name, "Failed to delete realm.") 232 | } 233 | } catch (e: Exception) { 234 | Log.w(javaClass.name, e.message ?: "Failed to delete realm.") 235 | } 236 | } 237 | 238 | fun pwnsByDomain(domain: String): List { 239 | val matches = ArrayList() 240 | 241 | for (pwnedSite in vault.pwned) { 242 | if (domain == pwnedSite.domain) { 243 | matches.add(pwnedSite) 244 | } 245 | } 246 | 247 | return matches 248 | } 249 | 250 | 251 | private fun save() { 252 | Realm.getDefaultInstance().use {it -> 253 | it.executeTransactionAsync { 254 | it.copyToRealmOrUpdate(vault) 255 | } 256 | } 257 | } 258 | 259 | fun setPwnedList(pwned: Map>) { 260 | pwned.values.forEach { list -> 261 | list.forEach { domain -> 262 | if (!vault.pwned.contains(domain)) { 263 | vault.pwned.add(domain) 264 | } 265 | } 266 | } 267 | save() 268 | } 269 | 270 | fun acknowledge(pwn: PwnedSite) { 271 | pwn.acknowledged = true 272 | save() 273 | } 274 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codingchili/mouse/enigma/model/FaviconLoader.kt: -------------------------------------------------------------------------------- 1 | package com.codingchili.mouse.enigma.model 2 | 3 | import android.content.Context 4 | import android.graphics.Bitmap 5 | import android.graphics.BitmapFactory 6 | import android.util.Log 7 | import android.util.TypedValue 8 | import com.jakewharton.disklrucache.DiskLruCache 9 | import com.loopj.android.http.AsyncHttpClient 10 | import com.loopj.android.http.AsyncHttpResponseHandler 11 | import com.loopj.android.http.RequestParams 12 | import cz.msebera.android.httpclient.Header 13 | import org.jsoup.Jsoup 14 | import org.jsoup.nodes.Document 15 | import java.io.OutputStream 16 | import java.util.* 17 | import kotlin.math.roundToInt 18 | 19 | 20 | /** 21 | * Loads the favicon of the given url. 22 | */ 23 | 24 | const val DP_SIZE = 96f // matches the maximum size we display in the UI. 25 | 26 | class FaviconLoader(private val context: Context) { 27 | private lateinit var cache: DiskLruCache 28 | 29 | init { 30 | open() 31 | } 32 | 33 | private fun open() { 34 | Performance("FaviconLoader:openCache").sync({ 35 | cache = DiskLruCache.open(context.cacheDir, 3, 1, 512_000_000) // 64MB disk cache. 36 | }) 37 | } 38 | 39 | /** 40 | * Retrieves the image from cache if the image has been loaded previously. 41 | * If the image has not been loaded previously then an image is retrieved 42 | * from the network. 43 | */ 44 | fun load(site: String, callback: (Bitmap) -> Unit, error: (Throwable?) -> Unit) { 45 | val cached: DiskLruCache.Snapshot? = cache.get(site.hashCode().toString()) 46 | 47 | if (cached != null) { 48 | Log.w("FaviconLoader", "IS IN CACHE: " + site.hashCode().toString()) 49 | callback.invoke(BitmapFactory.decodeStream(cached.getInputStream(0))) 50 | } else { 51 | Log.w("FaviconLoader", "IS NOT IN CACHE: " + site.hashCode().toString()) 52 | loadIconReferences(site, callback, error) 53 | } 54 | } 55 | 56 | /** 57 | * Retrieves the image from the cache if the image is cached. If the image is not 58 | * cached then the callback is never called. 59 | */ 60 | fun get(site: String, callback: (Bitmap) -> Unit, error: () -> Unit) { 61 | val cached: DiskLruCache.Snapshot? = cache.get(site.hashCode().toString()) 62 | if (cached == null) { 63 | error.invoke() 64 | } else { 65 | callback.invoke(BitmapFactory.decodeStream(cached.getInputStream(0))) 66 | } 67 | } 68 | 69 | /** 70 | * Removes all entries in the cache. 71 | */ 72 | fun clear() { 73 | cache.delete() 74 | open() 75 | 76 | } 77 | 78 | private fun loadIconReferences(site: String, callback: (Bitmap) -> Unit, error: (Throwable?) -> Unit) { 79 | val client = AsyncHttpClient() 80 | val params = RequestParams() 81 | 82 | try { 83 | client.get("https://$site", params, object : AsyncHttpResponseHandler() { 84 | 85 | override fun onSuccess(statusCode: Int, headers: Array?, responseBody: ByteArray?) { 86 | if (responseBody != null) { 87 | val document: Document = Jsoup.parse(String(responseBody)) 88 | 89 | // as loaded by browsers if no link-rel icon present. 90 | var largestLogoHref = "https://$site/favicon.ico" 91 | var largestIconSize = 0 92 | 93 | // find the biggest logo in the index HTML document. 94 | document.getElementsByTag("link").forEach { element -> 95 | val rel = element.attr("rel") 96 | 97 | Log.w("FaviconLoader", "element: " + element.toString()) 98 | 99 | if (rel.contains("icon") || rel.contains("shortcut") || rel.contains("apple-touch-icon")) { 100 | var size = 1 101 | 102 | if (element.hasAttr("sizes")) { 103 | try { 104 | size = Integer.parseInt(element.attr("sizes").split("x")[0]) 105 | } catch (exception: Exception) { 106 | // there is an icon but without a parse-able size. 107 | } 108 | } 109 | 110 | if (size > largestIconSize) { 111 | largestLogoHref = element.attr("href") 112 | largestIconSize = size 113 | } 114 | } 115 | } 116 | Log.w("FaviconLoader", "biggest logo chosen from $largestLogoHref size was $largestIconSize") 117 | loadImageFromNetwork(site, makeResourceUrl(site, largestLogoHref), callback, error) 118 | } 119 | } 120 | 121 | override fun onFailure(statusCode: Int, headers: Array?, responseBody: ByteArray?, exception: Throwable?) { 122 | if (exception == null) { 123 | error.invoke(Error("HTTP " + statusCode.toString())) 124 | } else { 125 | error.invoke(exception) 126 | } 127 | } 128 | }) 129 | } catch (e: Exception) { 130 | error.invoke(e) 131 | } 132 | } 133 | 134 | private fun makeResourceUrl(site: String, resource: String): String { 135 | var url: String = resource 136 | 137 | if (url.startsWith("//")) { 138 | url = resource.replace("//", "https://") 139 | } 140 | 141 | if (url.startsWith("/")) { 142 | url = "https://$site$resource" 143 | } 144 | 145 | if (!url.startsWith("http")) { 146 | url = "https://$site/$resource" 147 | } 148 | Log.w("faviconURL", "resolved $site icon resource $resource into $url") 149 | return url 150 | } 151 | 152 | private fun decodeImageFromBytes(bytes: ByteArray, url: String): Optional { 153 | return if (url.endsWith(".svg")) { 154 | // don't yet support .svg - just avoid crashing here. 155 | Optional.empty() 156 | } else { 157 | Optional.ofNullable(BitmapFactory.decodeByteArray(bytes, 0, bytes.size)) 158 | } 159 | } 160 | 161 | private fun loadImageFromNetwork(site: String, imageUrl: String, callback: (Bitmap) -> Unit, error: (Throwable?) -> Unit) { 162 | val client = AsyncHttpClient() 163 | 164 | try { 165 | client.get(imageUrl, RequestParams(), object : AsyncHttpResponseHandler() { 166 | 167 | override fun onSuccess(statusCode: Int, headers: Array
, response: ByteArray) { 168 | val px = TypedValue.applyDimension( 169 | TypedValue.COMPLEX_UNIT_DIP, 170 | DP_SIZE, 171 | context.resources.displayMetrics 172 | ).roundToInt() 173 | 174 | decodeImageFromBytes(response, imageUrl).ifPresent { bitmap -> 175 | var logo = bitmap 176 | 177 | if (logo.width > px || logo.height > px) { 178 | // only rescale if the image is larger than required. 179 | logo = Bitmap.createScaledBitmap(logo, px, px, true) 180 | } 181 | 182 | // callback before writing to disk. 183 | callback.invoke(logo) 184 | 185 | val editor: DiskLruCache.Editor = cache.edit(site.hashCode().toString()) 186 | val out: OutputStream = editor.newOutputStream(0) 187 | logo.compress(Bitmap.CompressFormat.WEBP, 100, out) 188 | editor.commit() 189 | } 190 | } 191 | 192 | override fun onFailure(statusCode: Int, headers: Array
?, errorResponse: ByteArray?, exception: Throwable?) { 193 | if (exception == null) { 194 | error.invoke(Error("HTTP " + statusCode.toString())) 195 | } else { 196 | error.invoke(exception) 197 | } 198 | } 199 | }) 200 | } catch (e: Exception) { 201 | error.invoke(e) 202 | } 203 | } 204 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codingchili/mouse/enigma/model/MousePreferences.kt: -------------------------------------------------------------------------------- 1 | package com.codingchili.mouse.enigma.model 2 | 3 | import android.app.Application 4 | import android.content.Context.MODE_PRIVATE 5 | import android.content.SharedPreferences 6 | import java.time.ZonedDateTime 7 | import java.time.format.DateTimeFormatter 8 | import java.util.* 9 | 10 | private const val KEY_INSTALLED = "KEY_INSTALLED" 11 | private const val TEE_IV = "TEE_IV" 12 | private const val MASTER_SALT = "MASTER_SALT" 13 | private const val MASTER_KEY = "MASTER_KEY" 14 | private const val CLIPBOARD_WARNING = "CLIPBOARD_WARNING" 15 | private const val FP_SUPPORTED = "FP_SUPPORTED" 16 | private const val PWNED_CHECK = "PWNED_CHECK" 17 | private const val LOCK_RESUME = "LOCK_RESUME" 18 | private const val DEVELOPER_OPTIONS = "DEV_OPTIONS" 19 | private const val DELAY_ACTIONS = "DELAY_ACTIONS" 20 | private const val fileName = "mouse.prefs" 21 | 22 | /** 23 | * Preferences wrapper. 24 | */ 25 | class MousePreferences(application: Application) { 26 | private var preferences : SharedPreferences = 27 | application.getSharedPreferences(fileName, MODE_PRIVATE) 28 | 29 | fun getTeeIv(): ByteArray { 30 | val iv: String = preferences.getString(TEE_IV, "")!! 31 | 32 | if (!iv.isBlank()) { 33 | return Base64.getDecoder().decode(iv) 34 | } else { 35 | throw Error("No IV is present.") 36 | } 37 | } 38 | 39 | fun getMasterSalt(): ByteArray { 40 | val salt: String = preferences.getString(MASTER_SALT, "")!! 41 | 42 | if (!salt.isBlank()) { 43 | return Base64.getDecoder().decode(salt) 44 | } else { 45 | throw Error("No master salt is present.") 46 | } 47 | } 48 | 49 | fun isKeyInstalled(): Boolean { 50 | return preferences.getBoolean(KEY_INSTALLED, false) 51 | } 52 | 53 | fun getEncryptedMaster(): ByteArray { 54 | val key: String = preferences.getString(MASTER_KEY, "")!! 55 | 56 | if (key.isBlank()) { 57 | throw Error("No master key in shared prefs.") 58 | } else { 59 | return Base64.getDecoder().decode(key) 60 | } 61 | } 62 | 63 | fun isClipboardWarningShown(): Boolean { 64 | return preferences.getBoolean(CLIPBOARD_WARNING, false) 65 | } 66 | 67 | fun setClipboardWarned(isWarned: Boolean): MousePreferences { 68 | preferences.edit() 69 | .putBoolean(CLIPBOARD_WARNING, isWarned) 70 | .apply() 71 | return this 72 | } 73 | 74 | fun setTeeIV(iv: ByteArray): MousePreferences { 75 | preferences.edit() 76 | .putString(TEE_IV, Base64.getEncoder().encodeToString(iv)) 77 | .apply() 78 | return this 79 | } 80 | 81 | fun setMasterSalt(salt: ByteArray): MousePreferences { 82 | preferences.edit().putString(MASTER_SALT, Base64.getEncoder().encodeToString(salt)).apply() 83 | return this 84 | } 85 | 86 | fun setInstalled(): MousePreferences { 87 | preferences.edit().putBoolean(KEY_INSTALLED, true).apply() 88 | return this 89 | } 90 | 91 | fun reset(): MousePreferences { 92 | preferences.edit() 93 | .putBoolean(KEY_INSTALLED, false) 94 | .putBoolean(FP_SUPPORTED, true) 95 | .putString(PWNED_CHECK, null) 96 | .putBoolean(CLIPBOARD_WARNING, false) 97 | .apply() 98 | return this 99 | } 100 | 101 | fun setEncryptedMaster(encryptedKey: ByteArray): MousePreferences { 102 | preferences.edit() 103 | .putString(MASTER_KEY, Base64.getEncoder().encodeToString(encryptedKey)) 104 | .apply() 105 | return this 106 | } 107 | 108 | fun setFPSupported(supported: Boolean): MousePreferences { 109 | preferences.edit() 110 | .putBoolean(FP_SUPPORTED, supported) 111 | .apply() 112 | return this 113 | } 114 | 115 | fun isSupportingFP(): Boolean { 116 | return preferences.getBoolean(FP_SUPPORTED, true) 117 | } 118 | 119 | fun setLastPwnedCheck(date: ZonedDateTime): MousePreferences { 120 | preferences.edit() 121 | .putString(PWNED_CHECK, date.format(DateTimeFormatter.ISO_DATE_TIME)) 122 | .apply() 123 | return this 124 | } 125 | 126 | fun setLockOnResume(enabled: Boolean) { 127 | preferences.edit() 128 | .putBoolean(LOCK_RESUME, enabled) 129 | .apply() 130 | } 131 | 132 | fun setDeveloperOptions(enabled: Boolean) { 133 | preferences.edit() 134 | .putBoolean(DEVELOPER_OPTIONS, enabled) 135 | .apply() 136 | } 137 | 138 | fun setDelayActions(enabled: Boolean) { 139 | preferences.edit() 140 | .putBoolean(DELAY_ACTIONS, enabled) 141 | .apply() 142 | } 143 | 144 | fun lockOnresume(): Boolean { 145 | return preferences.getBoolean(LOCK_RESUME, true) 146 | } 147 | 148 | fun developerOptions(): Boolean { 149 | return preferences.getBoolean(DEVELOPER_OPTIONS, false) 150 | } 151 | 152 | fun delayedActions(): Boolean { 153 | return preferences.getBoolean(DELAY_ACTIONS, true) 154 | } 155 | 156 | fun lastPwnedCheck(): ZonedDateTime { 157 | val date = Optional.ofNullable(preferences.getString(PWNED_CHECK, null)) 158 | 159 | val parsed = date.map { string -> 160 | ZonedDateTime.parse(string, DateTimeFormatter.ISO_DATE_TIME) 161 | } 162 | // if unset scan breaches in the last 6 months. 163 | return parsed.orElse(ZonedDateTime.now().minusMonths(6)) 164 | } 165 | } -------------------------------------------------------------------------------- /app/src/main/java/com/codingchili/mouse/enigma/model/Performance.kt: -------------------------------------------------------------------------------- 1 | package com.codingchili.mouse.enigma.model 2 | 3 | import android.util.Log 4 | 5 | /** 6 | * Utility class for logging performance metrics. 7 | */ 8 | class Performance(val name: String) { 9 | private var start: Long = 0L 10 | 11 | fun async(block: (() -> Unit) -> Unit) { 12 | sync({ 13 | block.invoke { 14 | complete() 15 | } 16 | }, true) 17 | } 18 | 19 | fun sync(block: () -> Unit, async: Boolean = false) { 20 | start = System.currentTimeMillis() 21 | 22 | block.invoke() 23 | if (!async) { 24 | complete() 25 | } 26 | } 27 | 28 | fun complete() { 29 | Log.w(javaClass.name, "$name completed in ${System.currentTimeMillis() - start} ms.") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingchili/mouse/enigma/model/PwnedChecker.kt: -------------------------------------------------------------------------------- 1 | package com.codingchili.mouse.enigma.model 2 | 3 | import android.app.Application 4 | import android.util.Log 5 | import com.loopj.android.http.AsyncHttpClient 6 | import com.loopj.android.http.AsyncHttpResponseHandler 7 | import com.loopj.android.http.RequestParams 8 | import cz.msebera.android.httpclient.Header 9 | import org.json.JSONArray 10 | import java.time.ZonedDateTime 11 | 12 | private const val API = "https://haveibeenpwned.com/api/v2/breaches" 13 | 14 | /** 15 | * Downloads the haveibeenpwned list of domains to check if any breaches 16 | * has occurred since last time scanning. 17 | */ 18 | class PwnedChecker(application: Application) { 19 | private var preferences = MousePreferences(application) 20 | 21 | fun check(sites: List, callback: (Map>) -> Unit, error: (Throwable) -> Unit) { 22 | val client = AsyncHttpClient() 23 | val params = RequestParams() 24 | 25 | try { 26 | client.get(API, params, object : AsyncHttpResponseHandler() { 27 | 28 | override fun onSuccess(statusCode: Int, headers: Array?, responseBody: ByteArray?) { 29 | if (responseBody != null) { 30 | val pwned = HashMap>(sites.size) 31 | val json = JSONArray(String(responseBody)) 32 | val add = { site: PwnedSite -> 33 | // only add pwn info if domain exists - scan needs to be re-run. 34 | if (sites.contains(site.domain)) { 35 | pwned.computeIfAbsent(site.domain) { ArrayList() } 36 | pwned[site.domain]!!.add(site) 37 | } 38 | } 39 | 40 | for (index in 0 until json.length()) { 41 | val site = PwnedSite(json.getJSONObject(index)) 42 | 43 | add.invoke(site) 44 | Log.w("PwnedChecker", "Detected breach on domain: " + site.domain) 45 | } 46 | preferences.setLastPwnedCheck(ZonedDateTime.now()) 47 | callback.invoke(pwned) 48 | } 49 | } 50 | 51 | override fun onFailure(statusCode: Int, headers: Array?, responseBody: ByteArray?, exception: Throwable?) { 52 | if (exception == null) { 53 | error.invoke(Error("HTTP " + statusCode.toString())) 54 | } else { 55 | error.invoke(exception) 56 | } 57 | } 58 | }) 59 | } catch (e: Exception) { 60 | error.invoke(e) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingchili/mouse/enigma/model/PwnedSite.kt: -------------------------------------------------------------------------------- 1 | package com.codingchili.mouse.enigma.model 2 | 3 | import io.realm.RealmObject 4 | import io.realm.annotations.PrimaryKey 5 | import org.json.JSONObject 6 | import java.time.ZonedDateTime 7 | import java.time.format.DateTimeFormatter 8 | import java.util.* 9 | 10 | /** 11 | * Contains information about a domain that has been pwned. 12 | */ 13 | private const val DOMAIN = "Domain" 14 | private const val ADDED_DATE = "AddedDate" 15 | private const val BREACH_DATE = "BreachDate" 16 | private const val DESCRIPTION = "Description" 17 | 18 | open class PwnedSite: RealmObject { 19 | lateinit var domain: String 20 | lateinit var description: String 21 | lateinit var added: String 22 | lateinit var discovered: String 23 | var acknowledged = false 24 | 25 | constructor() { 26 | // no-args Realm constructor. 27 | } 28 | 29 | constructor(json: JSONObject) { 30 | domain = json.getString(DOMAIN) 31 | description = json.getString(DESCRIPTION) 32 | added = json.getString(ADDED_DATE) 33 | discovered = json.getString(BREACH_DATE) 34 | } 35 | 36 | fun addedAfter(date: ZonedDateTime): Boolean { 37 | return ZonedDateTime.parse(added, DateTimeFormatter.ISO_DATE_TIME).isAfter(date) 38 | } 39 | 40 | override fun equals(other: Any?): Boolean { 41 | val casted = (other as PwnedSite) 42 | return casted.domain == domain && casted.discovered == discovered 43 | } 44 | 45 | override fun hashCode(): Int { 46 | return ("$domain.$discovered").hashCode() 47 | } 48 | 49 | @PrimaryKey 50 | var id: String = UUID.randomUUID().toString() 51 | } 52 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingchili/mouse/enigma/model/Vault.kt: -------------------------------------------------------------------------------- 1 | package com.codingchili.mouse.enigma.model 2 | 3 | import io.realm.RealmList 4 | import io.realm.RealmObject 5 | import io.realm.annotations.PrimaryKey 6 | import java.util.* 7 | 8 | /** 9 | * Wraps a set of credentials inside a vault. We can allow users 10 | * to switch between vaults later - and we can set and store 11 | * metadata securely - like an audit log for example. 12 | */ 13 | 14 | const val NAME_FIELD = "name" 15 | const val DEFAULT_NAME = "default" 16 | 17 | open class Vault: RealmObject() { 18 | 19 | @PrimaryKey 20 | var id: String = UUID.randomUUID().toString() 21 | var name = DEFAULT_NAME 22 | var credentials = RealmList() 23 | var log = RealmList() 24 | var pwned = RealmList() 25 | } 26 | -------------------------------------------------------------------------------- /app/src/main/java/com/codingchili/mouse/enigma/presenter/AddCredentialFragment.kt: -------------------------------------------------------------------------------- 1 | package com.codingchili.mouse.enigma.presenter 2 | 3 | import android.content.Context 4 | import android.os.Bundle 5 | import android.util.Log 6 | import android.view.LayoutInflater 7 | import android.view.View 8 | import android.view.ViewGroup 9 | import android.widget.* 10 | import androidx.core.content.ContextCompat 11 | import androidx.fragment.app.Fragment 12 | import com.codingchili.mouse.enigma.model.Credential 13 | import com.codingchili.mouse.enigma.model.CredentialBank 14 | import com.codingchili.mouse.enigma.model.FaviconLoader 15 | import com.google.android.material.floatingactionbutton.FloatingActionButton 16 | import com.google.android.material.textfield.TextInputEditText 17 | import org.spongycastle.util.encoders.Hex 18 | import java.security.SecureRandom 19 | import android.text.Editable 20 | import android.text.TextWatcher 21 | import android.view.inputmethod.InputMethodManager 22 | import com.codingchili.mouse.enigma.R 23 | import com.codingchili.mouse.enigma.model.AuditLogger 24 | 25 | 26 | /** 27 | * Fragment for adding new credentials. 28 | */ 29 | internal class AddCredentialFragment: Fragment() { 30 | private val random : SecureRandom = SecureRandom() 31 | 32 | override fun onCreate(savedInstanceState:Bundle?){ 33 | super.onCreate(savedInstanceState) 34 | setHasOptionsMenu(true) 35 | } 36 | 37 | override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState:Bundle?): View?{ 38 | val view: View = inflater.inflate(R.layout.fragment_add_credential,container,false) 39 | 40 | view.findViewById(R.id.cancel).setOnClickListener { 41 | FragmentSelector.back() 42 | } 43 | 44 | view.findViewById(R.id.generate).setOnClickListener { 45 | val generated : String = generate() 46 | Toast.makeText(context, generated, Toast.LENGTH_SHORT).show() 47 | val password = view.findViewById(R.id.password) 48 | 49 | password.setText(generated) 50 | 51 | if (activity != null) { 52 | val imm = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager? 53 | imm!!.hideSoftInputFromWindow(password.rootView.windowToken, 0) 54 | } 55 | } 56 | 57 | view.findViewById(R.id.website).setOnFocusChangeListener { v, hasFocus -> 58 | if (!hasFocus) { 59 | val edit : TextInputEditText = v as TextInputEditText 60 | 61 | FaviconLoader(requireContext()).load(edit.text.toString(), { bitmap -> 62 | view.findViewById(R.id.logo).setImageBitmap(bitmap) 63 | 64 | requireActivity().runOnUiThread { 65 | CredentialBank.onCacheUpdated() 66 | } 67 | }, { exception -> 68 | Log.w("AddCredentialFragment", exception?.message ?: "Failed to load ${edit.text}") 69 | }) 70 | } 71 | } 72 | 73 | view.findViewById(R.id.password).addTextChangedListener(object : TextWatcher { 74 | 75 | override fun afterTextChanged(s: Editable) { 76 | val input = view.findViewById(R.id.password) as TextInputEditText 77 | val password = input.text.toString() 78 | val strength : TextView = view.findViewById(R.id.strength) 79 | 80 | when (password.length) { 81 | in 1..10 -> { 82 | strength.setText(R.string.password_weak) 83 | strength.setTextColor(color(R.color.password_weak)) 84 | } 85 | in 10..14 -> { 86 | strength.setText(R.string.password_ok) 87 | strength.setTextColor(color(R.color.password_ok)) 88 | } 89 | in 14..999 -> { 90 | strength.setTextColor(color(R.color.password_strong)) 91 | strength.setText(R.string.password_strong) 92 | } 93 | else -> { 94 | strength.setText(R.string.password_generate_tap) 95 | strength.setTextColor(color(R.color.text)) 96 | } 97 | } 98 | } 99 | override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} 100 | override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} 101 | }) 102 | 103 | view.findViewById