├── .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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
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 [](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 | 
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 | [](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 | [](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