From 69114c3cc04e5fb448e6b3eef133dae13c340947 Mon Sep 17 00:00:00 2001 From: cali Date: Sun, 8 Sep 2024 18:49:27 +0200 Subject: [PATCH 001/136] first version credential provider --- README.md | 21 + app/build.gradle | 15 +- app/src/main/AndroidManifest.xml | 27 +- app/src/main/assets/trustedPackages.json | 536 ++++++++++++++++++ .../keepass/activities/EntryActivity.kt | 6 +- .../dialogs/DatabaseDialogFragment.kt | 1 + .../activities/dialogs/GroupDialogFragment.kt | 2 +- .../keepass/adapters/BreadcrumbAdapter.kt | 2 +- .../keepass/adapters/NodesAdapter.kt | 6 +- .../CredentialProviderActivity.kt | 185 ++++++ .../KeePassDXCredentialProviderService.kt | 179 ++++++ .../MyAuthenticatorAssertionResponse.kt | 114 ++++ .../keepass/credentialprovider/PasskeyUtil.kt | 93 +++ .../kunzisoft/keepass/education/Education.kt | 4 +- .../keepass/services/NotificationService.kt | 2 +- .../preference/DialogColorPreference.kt | 2 +- .../DialogListExplanationPreference.kt | 2 +- .../preference/DurationDialogPreference.kt | 3 +- .../preference/IconPackListPreference.kt | 2 +- .../preference/InputKdfNumberPreference.kt | 2 +- .../preference/InputKdfSizePreference.kt | 2 +- .../preference/InputListPreference.kt | 2 +- .../preference/InputNumberPreference.kt | 2 +- .../preference/InputSizePreference.kt | 2 +- .../preference/InputTextPreference.kt | 2 +- .../settings/preference/PreferenceConstant.kt | 8 + .../settings/preference/TextPreference.kt | 2 +- .../com/kunzisoft/keepass/view/SectionView.kt | 2 +- .../keepass/view/TemplateAbstractView.kt | 2 +- .../kunzisoft/keepass/view/ToolbarAction.kt | 2 +- .../kunzisoft/keepass/view/ToolbarSpecial.kt | 2 +- .../com/kunzisoft/keepass/view/ViewUtil.kt | 4 +- app/src/main/res/values/strings.xml | 4 + app/src/main/res/xml/provider.xml | 7 + build.gradle | 4 +- crypto/build.gradle | 4 +- .../java/com/kunzisoft/signature/Signature.kt | 43 ++ database/build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 3 +- icon-pack/build.gradle | 2 +- icon-pack/classic/build.gradle | 2 +- icon-pack/material/build.gradle | 2 +- 42 files changed, 1270 insertions(+), 39 deletions(-) create mode 100644 app/src/main/assets/trustedPackages.json create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/CredentialProviderActivity.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/KeePassDXCredentialProviderService.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/MyAuthenticatorAssertionResponse.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/PasskeyUtil.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/settings/preference/PreferenceConstant.kt create mode 100644 app/src/main/res/xml/provider.xml create mode 100644 crypto/src/main/java/com/kunzisoft/signature/Signature.kt diff --git a/README.md b/README.md index 745b444c5..364fd3b59 100644 --- a/README.md +++ b/README.md @@ -109,3 +109,24 @@ Other questions? You can read the [FAQ](https://github.com/Kunzisoft/KeePassDX/w along with KeePassDX. If not, see . *This project is a fork of [KeePassDroid](https://github.com/bpellin/keepassdroid) by bpellin.* + + +## Credential Provider + +Use this version only for testing at your own risk. + +### requirements +- Android 14 or up +- enable 3rd party passkeys in chrome. For detail see https://1password.community/discussion/comment/711037/#Comment_711037 +- set KeepassDX in the Android setting Passwords & Accounts > Your Provider > Enable + +### working +- sign in with ecdsa/rsa passkeys created by KeepassXC in Chrome. Tested with passkeys.io and webauthn.io. + +### maybe working +- sign in with passkeys apps natively (without browser) + +### not working +- create passkeys +- user credential provider with username/password +- open KeepassDX to unlock the database, if it is locked (currently a dummy entry with title unlock db is shown) \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index 561308a84..b466e81e1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,7 +9,7 @@ android { defaultConfig { applicationId "com.kunzisoft.keepass" - minSdkVersion 15 + minSdkVersion 19 targetSdkVersion 34 versionCode = 132 versionName = "4.1.0" @@ -35,6 +35,10 @@ android { } } + buildFeatures { + buildConfig true + } + dependenciesInfo { // Disables dependency metadata when building APKs. includeInApk = false @@ -98,6 +102,10 @@ android { kotlinOptions { jvmTarget = "1.8" } + + packaging { + resources.excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF") // necessary for bcpkix-jdk18on in crypto + } } def room_version = "2.5.1" @@ -122,6 +130,7 @@ dependencies { implementation "com.splitwise:tokenautocomplete:4.0.0-beta05" // Database implementation "androidx.room:room-runtime:$room_version" + implementation project(':crypto') kapt "androidx.room:room-compiler:$room_version" // Autofill implementation "androidx.autofill:autofill:1.1.0" @@ -136,6 +145,10 @@ dependencies { implementation 'commons-codec:commons-codec:1.15' // Password generator implementation 'me.gosimple:nbvcxz:1.5.0' + + // Credentials Provider + implementation "androidx.credentials:credentials:1.2.2" + implementation "androidx.credentials:credentials-play-services-auth:1.2.2" // Modules import implementation project(path: ':database') diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 070c5251c..5b881859b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,7 +44,9 @@ android:largeHeap="true" android:resizeableActivity="true" android:theme="@style/KeepassDXStyle.Night" - tools:targetApi="s"> + tools:targetApi="s" + android:enableOnBackInvokedCallback="true"> + @@ -199,6 +201,14 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/assets/trustedPackages.json b/app/src/main/assets/trustedPackages.json new file mode 100644 index 000000000..263805e82 --- /dev/null +++ b/app/src/main/assets/trustedPackages.json @@ -0,0 +1,536 @@ +{ + "apps": [ + { + "type": "android", + "info": { + "package_name": "com.android.chrome", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "DA:63:3D:34:B6:9E:63:AE:21:03:B4:9D:53:CE:05:2F:C5:F7:F3:C5:3A:AB:94:FD:C2:A2:08:BD:FD:14:24:9C" + }, + { + "build": "release", + "cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.dev", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "90:44:EE:5F:EE:4B:BC:5E:21:DD:44:66:54:31:C4:EB:1F:1F:71:A3:27:16:A0:BC:92:7B:CB:B3:92:33:CA:BF" + }, + { + "build": "release", + "cert_fingerprint_sha256": "3D:7A:12:23:01:9A:A3:9D:9E:A0:E3:43:6A:B7:C0:89:6B:FB:4F:B6:79:F4:DE:5F:E7:C2:3F:32:6C:8F:99:4A" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.chrome.canary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "20:19:DF:A1:FB:23:EF:BF:70:C5:BC:D1:44:3C:5B:EA:B0:4F:3F:2F:F4:36:6E:9A:C1:E3:45:76:39:A2:4C:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.chromium.chrome", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3" + }, + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.google.android.apps.chrome", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fennec_webauthndebug", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "BD:AE:82:02:80:D2:AF:B7:74:94:EF:22:58:AA:78:A9:AE:A1:36:41:7E:8B:C2:3D:C9:87:75:2E:6F:48:E8:48" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.firefox", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "A7:8B:62:A5:16:5B:44:94:B2:FE:AD:9E:76:A2:80:D2:2D:93:7F:EE:62:51:AE:CE:59:94:46:B2:EA:31:9B:04" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.firefox_beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "A7:8B:62:A5:16:5B:44:94:B2:FE:AD:9E:76:A2:80:D2:2D:93:7F:EE:62:51:AE:CE:59:94:46:B2:EA:31:9B:04" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.focus", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "62:03:A4:73:BE:36:D6:4E:E3:7F:87:FA:50:0E:DB:C7:9E:AB:93:06:10:AB:9B:9F:A4:CA:7D:5C:1F:1B:4F:FC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fennec_aurora", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "BC:04:88:83:8D:06:F4:CA:6B:F3:23:86:DA:AB:0D:D8:EB:CF:3E:77:30:78:74:59:F6:2F:B3:CD:14:A1:BA:AA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.rocket", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "86:3A:46:F0:97:39:32:B7:D0:19:9B:54:91:12:74:1C:2D:27:31:AC:72:EA:11:B7:52:3A:A9:0A:11:BF:56:91" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.canary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.dev", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "01:E1:99:97:10:A8:2C:27:49:B4:D5:0C:44:5D:C8:5D:67:0B:61:36:08:9D:0A:76:6A:73:82:7C:82:A1:EA:C9" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.rolling", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "32:A2:FC:74:D7:31:10:58:59:E5:A8:5D:F1:6D:95:F1:02:D8:5B:22:09:9B:80:64:C5:D8:91:5C:61:DA:D1:E0" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.microsoft.emmx.local", + "signatures": [ + { + "build": "userdebug", + "cert_fingerprint_sha256": "32:A2:FC:74:D7:31:10:58:59:E5:A8:5D:F1:6D:95:F1:02:D8:5B:22:09:9B:80:64:C5:D8:91:5C:61:DA:D1:E0" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser_beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.brave.browser_nightly", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "9C:2D:B7:05:13:51:5F:DB:FB:BC:58:5B:3E:DF:3D:71:23:D4:DC:67:C9:4F:FD:30:63:61:C1:D7:9B:BF:18:AC" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "app.vanadium.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C6:AD:B8:B8:3C:6D:4C:17:D2:92:AF:DE:56:FD:48:8A:51:D3:16:FF:8F:2C:11:C5:41:02:23:BF:F8:A7:DB:B3" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.vivaldi.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.vivaldi.browser.snapshot", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.vivaldi.browser.sopranos", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "E8:A7:85:44:65:5B:A8:C0:98:17:F7:32:76:8F:56:89:B1:66:2E:C4:B2:BC:5A:0B:C0:EC:13:8D:33:CA:3D:1E" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.citrix.Receiver", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "3D:D1:12:67:10:69:AB:36:4E:F9:BE:73:9A:B7:B5:EE:15:E1:CD:E9:D8:75:7B:1B:F0:64:F5:0C:55:68:9A:49" + }, + { + "build": "release", + "cert_fingerprint_sha256": "CE:B2:23:D7:77:09:F2:B6:BC:0B:3A:78:36:F5:A5:AF:4C:E1:D3:55:F4:A7:28:86:F7:9D:F8:0D:C9:D6:12:2E" + }, + { + "build": "release", + "cert_fingerprint_sha256": "AA:D0:D4:57:E6:33:C3:78:25:77:30:5B:C1:B2:D9:E3:81:41:C7:21:DF:0D:AA:6E:29:07:2F:C4:1D:34:F0:AB" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.android.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C9:00:9D:01:EB:F9:F5:D0:30:2B:C7:1B:2F:E9:AA:9A:47:A4:32:BB:A1:73:08:A3:11:1B:75:D7:B2:14:90:25" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.sec.android.app.sbrowser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C8:A2:E9:BC:CF:59:7C:2F:B6:DC:66:BE:E2:93:FC:13:F2:FC:47:EC:77:BC:6B:2B:0D:52:C1:1F:51:19:2A:B8" + }, + { + "build": "release", + "cert_fingerprint_sha256": "34:DF:0E:7A:9F:1C:F1:89:2E:45:C0:56:B4:97:3C:D8:1C:CF:14:8A:40:50:D1:1A:EA:4A:C5:A6:5F:90:0A:42" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.sec.android.app.sbrowser.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C8:A2:E9:BC:CF:59:7C:2F:B6:DC:66:BE:E2:93:FC:13:F2:FC:47:EC:77:BC:6B:2B:0D:52:C1:1F:51:19:2A:B8" + }, + { + "build": "release", + "cert_fingerprint_sha256": "34:DF:0E:7A:9F:1C:F1:89:2E:45:C0:56:B4:97:3C:D8:1C:CF:14:8A:40:50:D1:1A:EA:4A:C5:A6:5F:90:0A:42" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.google.android.gms", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "7C:E8:3C:1B:71:F3:D5:72:FE:D0:4C:8D:40:C5:CB:10:FF:75:E6:D8:7D:9D:F6:FB:D5:3F:04:68:C2:90:50:53" + }, + { + "build": "release", + "cert_fingerprint_sha256": "D2:2C:C5:00:29:9F:B2:28:73:A0:1A:01:0D:E1:C8:2F:BE:4D:06:11:19:B9:48:14:DD:30:1D:AB:50:CB:76:78" + }, + { + "build": "release", + "cert_fingerprint_sha256": "F0:FD:6C:5B:41:0F:25:CB:25:C3:B5:33:46:C8:97:2F:AE:30:F8:EE:74:11:DF:91:04:80:AD:6B:2D:60:DB:83" + }, + { + "build": "release", + "cert_fingerprint_sha256": "19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.alpha", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.corp", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "AC:A4:05:DE:D8:B2:5C:B2:E8:C6:DA:69:42:5D:2B:43:07:D0:87:C1:27:6F:C0:6A:D5:94:27:31:CC:C5:1D:BA" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.canary", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "1D:A9:CB:AE:2D:CC:C6:A5:8D:6C:94:7B:E9:4C:DB:B7:33:D6:5D:A4:D1:77:0F:A1:4A:53:64:CB:4A:28:EB:49" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.yandex.browser.broteam", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "1D:A9:CB:AE:2D:CC:C6:A5:8D:6C:94:7B:E9:4C:DB:B7:33:D6:5D:A4:D1:77:0F:A1:4A:53:64:CB:4A:28:EB:49" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.talonsec.talon", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "A3:66:03:44:A6:F6:AF:CA:81:8C:BF:43:96:A2:3C:CF:D5:ED:7A:78:1B:B4:A3:D1:85:03:01:E2:F4:6D:23:83" + }, + { + "build": "release", + "cert_fingerprint_sha256": "E2:A5:64:74:EA:23:7B:06:67:B6:F5:2C:DC:E9:04:5E:24:88:3B:AE:D0:82:59:9A:A2:DF:0B:60:3A:CF:6A:3B" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.talonsec.talon_beta", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "F5:86:62:7A:32:C8:9F:E6:7E:00:6D:B1:8C:34:31:9E:01:7F:B3:B2:BE:D6:9D:01:01:B7:F9:43:E7:7C:48:AE" + }, + { + "build": "release", + "cert_fingerprint_sha256": "9A:A1:25:D5:E5:5E:3F:B0:DE:96:72:D9:A9:5D:04:65:3F:49:4A:1E:C3:EE:76:1E:94:C4:4E:5D:2F:65:8E:2F" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.duckduckgo.mobile.android.debug", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "C4:F0:9E:2B:D7:25:AD:F5:AD:92:0B:A2:80:27:66:AC:16:4A:C1:53:B3:EA:9E:08:48:B0:57:98:37:F7:6A:29" + } + ] + } + }, + { + "type": "android", + "info": { + "package_name": "com.duckduckgo.mobile.android", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "BB:7B:B3:1C:57:3C:46:A1:DA:7F:C5:C5:28:A6:AC:F4:32:10:84:56:FE:EC:50:81:0C:7F:33:69:4E:B3:D2:D4" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt index 5da1544b8..75bc8c5f0 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt @@ -163,9 +163,9 @@ class EntryActivity : DatabaseLockActivity() { toolbar?.title = " " // Retrieve the textColor to tint the toolbar - val taColorSecondary = theme.obtainStyledAttributes(intArrayOf(R.attr.colorSecondary)) - val taColorSurface = theme.obtainStyledAttributes(intArrayOf(R.attr.colorSurface)) - val taColorOnSurface = theme.obtainStyledAttributes(intArrayOf(R.attr.colorOnSurface)) + val taColorSecondary = theme.obtainStyledAttributes(intArrayOf(com.google.android.material.R.attr.colorSecondary)) + val taColorSurface = theme.obtainStyledAttributes(intArrayOf(com.google.android.material.R.attr.colorSurface)) + val taColorOnSurface = theme.obtainStyledAttributes(intArrayOf(com.google.android.material.R.attr.colorOnSurface)) val taColorBackground = theme.obtainStyledAttributes(intArrayOf(android.R.attr.windowBackground)) mColorSecondary = taColorSecondary.getColor(0, Color.BLACK) mColorSurface = taColorSurface.getColor(0, Color.BLACK) diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseDialogFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseDialogFragment.kt index 3c83c7750..1050ba7b8 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseDialogFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/DatabaseDialogFragment.kt @@ -30,6 +30,7 @@ abstract class DatabaseDialogFragment : DialogFragment(), DatabaseRetrieval { } @Suppress("DEPRECATION") + @Deprecated(message = "") override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GroupDialogFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GroupDialogFragment.kt index 593a99d6f..6a41fb3bb 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GroupDialogFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/dialogs/GroupDialogFragment.kt @@ -108,7 +108,7 @@ class GroupDialogFragment : DatabaseDialogFragment() { uuidReferenceView = root.findViewById(R.id.group_UUID_reference) // Retrieve the textColor to tint the icon - val ta = activity.theme.obtainStyledAttributes(intArrayOf(R.attr.colorSecondary)) + val ta = activity.theme.obtainStyledAttributes(intArrayOf(com.google.android.material.R.attr.colorSecondary)) mIconColor = ta.getColor(0, Color.WHITE) ta.recycle() diff --git a/app/src/main/java/com/kunzisoft/keepass/adapters/BreadcrumbAdapter.kt b/app/src/main/java/com/kunzisoft/keepass/adapters/BreadcrumbAdapter.kt index 6b0dafb2b..1e073e8ae 100644 --- a/app/src/main/java/com/kunzisoft/keepass/adapters/BreadcrumbAdapter.kt +++ b/app/src/main/java/com/kunzisoft/keepass/adapters/BreadcrumbAdapter.kt @@ -40,7 +40,7 @@ class BreadcrumbAdapter(val context: Context) mShowUUID = PreferencesUtil.showUUID(context) // Retrieve the color to tint the icon - val taIconColor = context.theme.obtainStyledAttributes(intArrayOf(R.attr.colorOnSurface)) + val taIconColor = context.theme.obtainStyledAttributes(intArrayOf(com.google.android.material.R.attr.colorOnSurface)) mIconColor = taIconColor.getColor(0, Color.WHITE) taIconColor.recycle() } diff --git a/app/src/main/java/com/kunzisoft/keepass/adapters/NodesAdapter.kt b/app/src/main/java/com/kunzisoft/keepass/adapters/NodesAdapter.kt index fc47ca14f..9961a8675 100644 --- a/app/src/main/java/com/kunzisoft/keepass/adapters/NodesAdapter.kt +++ b/app/src/main/java/com/kunzisoft/keepass/adapters/NodesAdapter.kt @@ -118,7 +118,7 @@ class NodesAdapter ( this.mNodeSortedListCallback = NodeSortedListCallback() this.mNodeSortedList = SortedList(Node::class.java, mNodeSortedListCallback) - val taColorSurfaceContainer = context.obtainStyledAttributes(intArrayOf(R.attr.colorSurfaceContainer)) + val taColorSurfaceContainer = context.obtainStyledAttributes(intArrayOf(com.google.android.material.R.attr.colorSurfaceContainer)) this.mColorSurfaceContainer = taColorSurfaceContainer.getColor(0, Color.BLACK) taColorSurfaceContainer.recycle() // Retrieve the color to tint the icon @@ -134,11 +134,11 @@ class NodesAdapter ( this.mTextColorSecondary = taTextColorSecondary.getColor(0, Color.BLACK) taTextColorSecondary.recycle() // To get background color for selection - val taColorSecondary = context.obtainStyledAttributes(intArrayOf(R.attr.colorSecondary)) + val taColorSecondary = context.obtainStyledAttributes(intArrayOf(com.google.android.material.R.attr.colorSecondary)) this.mColorSecondary = taColorSecondary.getColor(0, Color.GRAY) taColorSecondary.recycle() // To get text color for selection - val taColorOnSecondary = context.obtainStyledAttributes(intArrayOf(R.attr.colorOnSecondary)) + val taColorOnSecondary = context.obtainStyledAttributes(intArrayOf(com.google.android.material.R.attr.colorOnSecondary)) this.mColorOnSecondary = taColorOnSecondary.getColor(0, Color.WHITE) taColorOnSecondary.recycle() } diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/CredentialProviderActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/CredentialProviderActivity.kt new file mode 100644 index 000000000..62d20ea7e --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/CredentialProviderActivity.kt @@ -0,0 +1,185 @@ +package com.kunzisoft.keepass.credentialprovider + +import android.annotation.SuppressLint +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPasswordOption +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.PendingIntentHandler +import androidx.credentials.webauthn.MyAuthenticatorAssertionResponse +import androidx.credentials.webauthn.FidoPublicKeyCredential +import androidx.credentials.webauthn.PublicKeyCredentialRequestOptions +import com.kunzisoft.signature.Signature +import com.kunzisoft.keepass.activities.legacy.DatabaseActivity +import com.kunzisoft.keepass.database.ContextualDatabase +import org.apache.commons.codec.binary.Base64 +import androidx.biometric.BiometricPrompt; +import com.kunzisoft.keepass.R + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class CredentialProviderActivity : DatabaseActivity() { + + @SuppressLint("RestrictedApi") + private fun validatePasskey(requestJson: String, origin: String, packageName: String, uid: ByteArray, username: Any, credId: ByteArray, privateKey: String) { + + val request = PublicKeyCredentialRequestOptions(requestJson) + + // https://www.w3.org/TR/webauthn-3/#authdata-flags + val userPresent = true + val userVerified = true + val backupEligibility = true + val backupState = true + val response = MyAuthenticatorAssertionResponse( + requestOptions = request, + credentialId = credId, + origin = origin, + up = userPresent, + uv = userVerified, + be = backupEligibility, + bs = backupState, + userHandle = uid + ) + + val messageToSign = response.dataToSign() + + val sig = Signature.sign(privateKey, messageToSign) + + response.signature = sig + + val credential = FidoPublicKeyCredential( + rawId = credId, response = response, authenticatorAttachment = "platform" + ) + val result = Intent() + + val cJson = credential.json() + Log.w("", cJson) + val passkeyCredential = PublicKeyCredential(cJson) + PendingIntentHandler.setGetCredentialResponse( + result, GetCredentialResponse(passkeyCredential) + ) + setResult(RESULT_OK, result) + finish() + } + + private fun b64Decode(encodedString: String?): ByteArray { + return Base64.decodeBase64(encodedString) + } + + private fun cleanUp() { + setResult(RESULT_CANCELED) + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + + override fun onDatabaseRetrieved(database: ContextualDatabase?) { + Log.d(javaClass.simpleName,"onDatabaseRetrieved called: database = $database") + super.onDatabaseRetrieved(database) + + val getRequest = + PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) + + if (getRequest?.credentialOptions?.size != 1) { + throw Exception("not exact 1 credentialOption") + } + + when (val credOption = getRequest.credentialOptions[0]) { + is GetPublicKeyCredentialOption -> handlePublicKeyCredOption(credOption, getRequest.callingAppInfo) + is GetPasswordOption -> handlePasswordOption(credOption) + else -> throw Exception("unknown type of credentialOption") + } + + Log.d(javaClass.simpleName, "onDatabaseRetrieved finished") + + } + private fun handlePublicKeyCredOption(publicKeyRequest: GetPublicKeyCredentialOption, callingAppInfo: CallingAppInfo) { + + val requestInfo = intent.getBundleExtra(KeePassDXCredentialProviderService.INTENT_EXTRA_KEY) + val nodeId = requestInfo?.getString(KeePassDXCredentialProviderService.NODE_ID_KEY) + + Log.d(javaClass.simpleName, "nodeId = $nodeId") + + if (mDatabase == null || nodeId == null) { + cleanUp() + return + } + val passkey = PasskeyUtil.searchPassKeyByNodeId(mDatabase!!, nodeId) + + + if (passkey == null) { + cleanUp() + return + } + Log.d(javaClass.simpleName, "passkey found") + + val credId = b64Decode(passkey.credId) + val privateKey = passkey.privateKeyPem + val uid = b64Decode(passkey.userHandle) + + val origin = appInfoToOrigin(callingAppInfo) + + Log.d(javaClass.simpleName, "origin = $origin") + val packageName = callingAppInfo.packageName + + val biometricPrompt = BiometricPrompt( + this, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + cleanUp() + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + cleanUp() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + validatePasskey( + publicKeyRequest.requestJson, + origin!!, + packageName, + uid, + passkey.username, + credId, + privateKey + ) + } + } + ) + + val title = getString(R.string.passkey_biometric_prompt_title) + val subtitle = getString(R.string.passkey_biometric_prompt_subtitle, origin) + val negativeButtonText = getString(R.string.passkey_biometric_prompt_negative_button_text) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .setNegativeButtonText(negativeButtonText) + .build() + biometricPrompt.authenticate(promptInfo) + } + + private fun handlePasswordOption(passwordOption: GetPasswordOption) { + // TODO + } + + private fun appInfoToOrigin(callingAppInfo: CallingAppInfo): String? { + val privilegedAllowlist = assets.open("trustedPackages.json").bufferedReader().use { + it.readText() + } + return callingAppInfo.getOrigin(privilegedAllowlist)?.removeSuffix("/") + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/KeePassDXCredentialProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/KeePassDXCredentialProviderService.kt new file mode 100644 index 000000000..eaaba0c35 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/KeePassDXCredentialProviderService.kt @@ -0,0 +1,179 @@ +package com.kunzisoft.keepass.credentialprovider; + +import android.app.PendingIntent +import android.content.Intent +import android.os.Bundle +import android.os.CancellationSignal; +import android.os.OutcomeReceiver; +import android.provider.ContactsContract.Directory.PACKAGE_NAME + +import androidx.annotation.RequiresApi +import androidx.credentials.exceptions.ClearCredentialException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.GetCredentialException; +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.BeginCreateCredentialRequest +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginGetCredentialRequest +import androidx.credentials.provider.BeginGetCredentialResponse; +import androidx.credentials.provider.BeginGetPasswordOption +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.CallingAppInfo +import androidx.credentials.provider.CredentialEntry +import androidx.credentials.provider.CredentialProviderService +import androidx.credentials.provider.ProviderClearCredentialStateRequest +import androidx.credentials.provider.PublicKeyCredentialEntry +import org.json.JSONObject + +import android.util.Log +import com.kunzisoft.keepass.database.DatabaseTaskProvider +import com.kunzisoft.keepass.database.element.Database +import com.kunzisoft.keepass.database.search.SearchHelper +import com.kunzisoft.keepass.database.search.SearchParameters + +@RequiresApi(value = 34) +class KeePassDXCredentialProviderService : CredentialProviderService() { + + private var mDatabaseTaskProvider: DatabaseTaskProvider? = null + private var mDatabase: Database? = null + + override fun onCreate() { + super.onCreate() + + mDatabaseTaskProvider = DatabaseTaskProvider(this) + mDatabaseTaskProvider?.registerProgressTask() + mDatabaseTaskProvider?.onDatabaseRetrieved = { database -> + this.mDatabase = database + } + } + + override fun onDestroy() { + mDatabaseTaskProvider?.unregisterProgressTask() + super.onDestroy() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return super.onStartCommand(intent, flags, startId) + } + + override fun onBeginCreateCredentialRequest(request: BeginCreateCredentialRequest, cancellationSignal: CancellationSignal, callback: OutcomeReceiver) { + TODO("Not yet implemented") + + } + + override fun onBeginGetCredentialRequest( + request: BeginGetCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + /* + val unlockEntryTitle = "Authenticate to continue" + if (isAppLocked()) { + callback.onResult(BeginGetCredentialResponse( + authenticationActions = mutableListOf(AuthenticationAction( + unlockEntryTitle, createUnlockPendingIntent()) + ) + ) + ) + return + } + */ + try { + val response = processGetCredentialsRequest(request) + callback.onResult(response) + } catch (e: GetCredentialException) { + callback.onError(GetCredentialUnknownException()) + } + } + + companion object { + private const val GET_PASSKEY_INTENT_ACTION = "com.kunzisoft.keepass.credentialprovider.GET_PASSKEY" + private const val GET_PASSWORD_INTENT_ACTION = "com.kunzisoft.keepass.credentialprovider.GET_PASSWORD" + + const val NODE_ID_KEY = "nodeId" + const val INTENT_EXTRA_KEY = "CREDENTIAL_DATA" + } + + private fun processGetCredentialsRequest( + request: BeginGetCredentialRequest + ): BeginGetCredentialResponse { + + val callingAppInfo = request.callingAppInfo ?: throw Exception("callingAppInfo is null") + val credentialEntries: MutableList = mutableListOf() + + for (option in request.beginGetCredentialOptions) { + when (option) { + is BeginGetPasswordOption -> { + // TODO + } + is BeginGetPublicKeyCredentialOption -> { + credentialEntries.addAll( + populatePasskeyData(callingAppInfo, option) + ) + } else -> { + Log.d(javaClass.simpleName,"Request not supported") + } + } + } + return BeginGetCredentialResponse(credentialEntries) + } + + private fun populatePasskeyData(callingAppInfo: CallingAppInfo, option: BeginGetPublicKeyCredentialOption): List { + + val json = JSONObject(option.requestJson) + + val relyingPartyId = json.optString("rpId", "") + + val passkeys = getCredentialsFromDb(relyingPartyId) + + val passkeyEntries: MutableList = mutableListOf() + for (passkey in passkeys) { + val data = Bundle() + data.putString(NODE_ID_KEY, passkey.nodeId) + passkeyEntries.add( + PublicKeyCredentialEntry( + context = applicationContext, + username = passkey.username, + pendingIntent = createNewPendingIntent( + GET_PASSKEY_INTENT_ACTION, + data + ), + beginGetPublicKeyCredentialOption = option, + displayName = passkey.displayName, + lastUsedTime = passkey.lastUsedTime, + isAutoSelectAllowed = false + ) + ) + } + return passkeyEntries + } + + private fun getCredentialsFromDb(relyingPartyId: String) : List { + if (mDatabase == null) { + // TODO make sure that the database is open + val dummyPassKey = PasskeyUtil.Passkey("", "unknown", "unlock db", "", "", "", "", null) + return listOf(dummyPassKey) + } + val passkeys = PasskeyUtil.searchPasskeys(mDatabase!!) + val passkeysMatching = passkeys.filter { p -> p.relyingParty == relyingPartyId } + return passkeysMatching + } + + + private fun createNewPendingIntent(action: String, extra: Bundle? = null): PendingIntent { + val intent = Intent(action).setPackage(PACKAGE_NAME).setClass(applicationContext, CredentialProviderActivity::class.java) + if (extra != null) { + intent.putExtra(INTENT_EXTRA_KEY, extra) + } + val requestCode = 42 // not used + return PendingIntent.getActivity( + applicationContext, requestCode, intent, + (PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + ) + } + + override fun onClearCredentialStateRequest(request: ProviderClearCredentialStateRequest, cancellationSignal: CancellationSignal, callback: OutcomeReceiver) { + // nothing to do + } + +} diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/MyAuthenticatorAssertionResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/MyAuthenticatorAssertionResponse.kt new file mode 100644 index 000000000..d8536a26f --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/MyAuthenticatorAssertionResponse.kt @@ -0,0 +1,114 @@ +/* +* Copyright 2023 The Android Open Source Project +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +package androidx.credentials.webauthn + +import android.annotation.SuppressLint +import android.util.Log +import java.security.MessageDigest +import org.json.JSONObject +import org.apache.commons.codec.binary.Base64 + +@SuppressLint("RestrictedApi") +class MyAuthenticatorAssertionResponse( + private val requestOptions: PublicKeyCredentialRequestOptions, + private val credentialId: ByteArray, + private val origin: String, + private val up: Boolean, + private val uv: Boolean, + private val be: Boolean, + private val bs: Boolean, + private var userHandle: ByteArray, + private val packageName: String? = null, + private val clientDataHash: ByteArray? = null, +) : AuthenticatorResponse { + override var clientJson = JSONObject() + var authenticatorData: ByteArray + var signature: ByteArray = byteArrayOf() + + init { + clientJson.put("type", "webauthn.get") + clientJson.put("challenge", b64Encode(requestOptions.challenge)) + clientJson.put("origin", origin) + clientJson.put("crossOrigin", false) + if (packageName != null) { + clientJson.put("androidPackageName", packageName) + } + + authenticatorData = defaultAuthenticatorData() + } + + fun defaultAuthenticatorData(): ByteArray { + val md = MessageDigest.getInstance("SHA-256") + val rpHash = md.digest(requestOptions.rpId.toByteArray()) + var flags: Int = 0 + if (up) { + flags = flags or 0x01 + } + if (uv) { + flags = flags or 0x04 + } + if (be) { + flags = flags or 0x08 + } + if (bs) { + flags = flags or 0x10 + } + val ret = rpHash + byteArrayOf(flags.toByte()) + byteArrayOf(0, 0, 0, 0) + return ret + } + + fun dataToSign(): ByteArray { + val md = MessageDigest.getInstance("SHA-256") + var hash: ByteArray + if (clientDataHash != null) { + hash = clientDataHash + } else { + hash = md.digest(temp().toByteArray()) + } + + return authenticatorData + hash + } + + override fun json(): JSONObject { + + val clientJsonTemp = temp() + Log.w("", clientJsonTemp) + val clientData = clientJsonTemp.toByteArray() + val response = JSONObject() + if (clientDataHash == null) { + response.put("clientDataJSON", b64Encode(clientData)) + } + response.put("authenticatorData", b64Encode(authenticatorData)) + response.put("signature", b64Encode(signature)) + response.put("userHandle", b64Encode(userHandle)) + return response + } + + fun temp(): String { + val clientJsonTemp = clientJson.toString() + val clientJsonGood = clientJsonTemp.replace("\\/", "/") + return clientJsonGood + } + + private fun b64Decode(encodedString: String?): ByteArray { + return Base64.decodeBase64(encodedString) + } + + private fun b64Encode(binData: ByteArray?): String { + return Base64.encodeBase64URLSafeString(binData) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/PasskeyUtil.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/PasskeyUtil.kt new file mode 100644 index 000000000..c1b578c01 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/PasskeyUtil.kt @@ -0,0 +1,93 @@ +package com.kunzisoft.keepass.credentialprovider + +import android.os.Build +import com.kunzisoft.keepass.database.element.Database +import com.kunzisoft.keepass.database.element.Entry +import com.kunzisoft.keepass.database.element.node.NodeIdUUID +import com.kunzisoft.keepass.database.search.SearchHelper +import com.kunzisoft.keepass.database.search.SearchParameters +import com.kunzisoft.keepass.utils.UuidUtil +import java.time.Instant + +class PasskeyUtil { + + data class Passkey(val nodeId: String, val username: String, val displayName: String, val privateKeyPem: String, val credId: String, val userHandle: String, val relyingParty: String, val lastUsedTime: Instant?) + + companion object { + + const val PASSKEY_TAG = "Passkey" + fun convertEntryToPasskey(entry: Entry): Passkey? { + if (!entry.tags.toList().contains(PASSKEY_TAG)) { + return null + } + + val nodeId = UuidUtil.toHexString(entry.nodeId.id)!! + + val displayName = entry.getVisualTitle() + val lastUsedTime = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + entry.lastAccessTime.date.toInstant() + } else { + null + } + var username = "" + var privateKeyPem = "" + var credId = "" + var userHandle = "" + var relyingParty = "" + + for (field in entry.getExtraFields()) { + val fieldName = field.name + + // field names from KeypassXC are used + if (fieldName == "KPEX_PASSKEY_USERNAME") { + username = field.protectedValue.stringValue + } else if (field.name == "KPEX_PASSKEY_PRIVATE_KEY_PEM") { + privateKeyPem = field.protectedValue.stringValue + } else if (field.name == "KPEX_PASSKEY_CREDENTIAL_ID") { + credId = field.protectedValue.stringValue + } else if (field.name == "KPEX_PASSKEY_USER_HANDLE") { + userHandle = field.protectedValue.stringValue + } else if (field.name == "KPEX_PASSKEY_RELYING_PARTY") { + relyingParty = field.protectedValue.stringValue + } + // KPEX_PASSKEY_RELYING_PARTY + } + return Passkey(nodeId, username, displayName, privateKeyPem, credId, userHandle, relyingParty, lastUsedTime) + } + + fun convertEntriesListToPasskeys(entries: List): List { + return entries.mapNotNull { e -> convertEntryToPasskey(e) } + } + + fun searchPasskeys(database: Database): List { + val searchHelper = SearchHelper() + val searchParameters = SearchParameters().apply { + searchQuery = PASSKEY_TAG + searchInTitles = false + searchInUsernames = false + searchInPasswords = false + searchInUrls = false + searchInNotes = false + searchInOTP = false + searchInOther = false + searchInUUIDs = false + searchInTags = true + searchInCurrentGroup = false + searchInSearchableGroup = false + searchInRecycleBin = false + searchInTemplates = false + } + val searchResult = searchHelper.createVirtualGroupWithSearchResult(database, searchParameters, null, Int.MAX_VALUE) + ?: return emptyList() + + return convertEntriesListToPasskeys(searchResult.getChildEntries()) + } + + fun searchPassKeyByNodeId(database: Database, nodeId: String): Passkey? { + val uuidToSearch = UuidUtil.fromHexString(nodeId)!! + val nodeIdUUIDToSearch = NodeIdUUID(uuidToSearch) + val entry = database.getEntryById(nodeIdUUIDToSearch)!! + return convertEntryToPasskey(entry) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/education/Education.kt b/app/src/main/java/com/kunzisoft/keepass/education/Education.kt index 1bd0f9a14..4cfaad746 100644 --- a/app/src/main/java/com/kunzisoft/keepass/education/Education.kt +++ b/app/src/main/java/com/kunzisoft/keepass/education/Education.kt @@ -98,7 +98,7 @@ open class Education(val activity: Activity) { } protected fun getCircleColor(): Int { - val typedArray = activity.obtainStyledAttributes(intArrayOf(R.attr.colorPrimaryContainer)) + val typedArray = activity.obtainStyledAttributes(intArrayOf(com.google.android.material.R.attr.colorPrimaryContainer)) val colorControl = typedArray.getColor(0, Color.GREEN) typedArray.recycle() return colorControl @@ -109,7 +109,7 @@ open class Education(val activity: Activity) { } protected fun getTextColor(): Int { - val typedArray = activity.obtainStyledAttributes(intArrayOf(R.attr.colorOnPrimaryContainer)) + val typedArray = activity.obtainStyledAttributes(intArrayOf(com.google.android.material.R.attr.colorOnPrimaryContainer)) val colorControl = typedArray.getColor(0, Color.WHITE) typedArray.recycle() return colorControl diff --git a/app/src/main/java/com/kunzisoft/keepass/services/NotificationService.kt b/app/src/main/java/com/kunzisoft/keepass/services/NotificationService.kt index a3eb2bf9b..774272583 100644 --- a/app/src/main/java/com/kunzisoft/keepass/services/NotificationService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/services/NotificationService.kt @@ -70,7 +70,7 @@ abstract class NotificationService : Service() { setTheme(Stylish.getThemeId(this)) val typedValue = TypedValue() val theme = theme - theme.resolveAttribute(R.attr.colorPrimary, typedValue, true) + theme.resolveAttribute(com.google.android.material.R.attr.colorPrimary, typedValue, true) colorNotificationAccent = typedValue.data } diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preference/DialogColorPreference.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preference/DialogColorPreference.kt index 68ff04335..134848ef4 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preference/DialogColorPreference.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preference/DialogColorPreference.kt @@ -29,7 +29,7 @@ import com.kunzisoft.keepass.R class DialogColorPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.dialogPreferenceStyle, + defStyleAttr: Int = PreferenceConstant.R_ATTR_DIALOG_PREFERENCE_STYLE, defStyleRes: Int = defStyleAttr) : ChromaPreferenceCompat(context, attrs, defStyleAttr, defStyleRes) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preference/DialogListExplanationPreference.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preference/DialogListExplanationPreference.kt index c0a028bbe..83d2531b5 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preference/DialogListExplanationPreference.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preference/DialogListExplanationPreference.kt @@ -27,7 +27,7 @@ import com.kunzisoft.keepass.R class DialogListExplanationPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.dialogPreferenceStyle, + defStyleAttr: Int = PreferenceConstant.R_ATTR_DIALOG_PREFERENCE_STYLE, defStyleRes: Int = defStyleAttr) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preference/DurationDialogPreference.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preference/DurationDialogPreference.kt index 46f7a4e92..6d1730e27 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preference/DurationDialogPreference.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preference/DurationDialogPreference.kt @@ -27,7 +27,7 @@ import com.kunzisoft.keepass.R class DurationDialogPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.dialogPreferenceStyle, + defStyleAttr: Int = PreferenceConstant.R_ATTR_DIALOG_PREFERENCE_STYLE, defStyleRes: Int = defStyleAttr) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) { @@ -52,6 +52,7 @@ class DurationDialogPreference @JvmOverloads constructor(context: Context, notifyChanged() } + @Deprecated(message = "") override fun onSetInitialValue(restorePersistedValue: Boolean, defaultValue: Any?) { if (restorePersistedValue) { mDuration = getPersistedString(mDuration.toString()).toLongOrNull() ?: mDuration diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preference/IconPackListPreference.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preference/IconPackListPreference.kt index 3ed4a43ec..6d99c99f3 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preference/IconPackListPreference.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preference/IconPackListPreference.kt @@ -28,7 +28,7 @@ import java.util.* class IconPackListPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.dialogPreferenceStyle, + defStyleAttr: Int = PreferenceConstant.R_ATTR_DIALOG_PREFERENCE_STYLE, defStyleRes: Int = defStyleAttr) : ListPreference(context, attrs, defStyleAttr, defStyleRes) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputKdfNumberPreference.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputKdfNumberPreference.kt index 367f73489..0be703810 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputKdfNumberPreference.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputKdfNumberPreference.kt @@ -27,7 +27,7 @@ import com.kunzisoft.keepass.database.crypto.kdf.KdfEngine open class InputKdfNumberPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.dialogPreferenceStyle, + defStyleAttr: Int = PreferenceConstant.R_ATTR_DIALOG_PREFERENCE_STYLE, defStyleRes: Int = defStyleAttr) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputKdfSizePreference.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputKdfSizePreference.kt index ff07d973d..cb691ae88 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputKdfSizePreference.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputKdfSizePreference.kt @@ -26,7 +26,7 @@ import com.kunzisoft.keepass.utils.DataByte class InputKdfSizePreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.dialogPreferenceStyle, + defStyleAttr: Int = PreferenceConstant.R_ATTR_DIALOG_PREFERENCE_STYLE, defStyleRes: Int = defStyleAttr) : InputKdfNumberPreference(context, attrs, defStyleAttr, defStyleRes) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputListPreference.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputListPreference.kt index 708ba3fd1..eaaee1223 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputListPreference.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputListPreference.kt @@ -26,7 +26,7 @@ import com.kunzisoft.keepass.R open class InputListPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.dialogPreferenceStyle, + defStyleAttr: Int = PreferenceConstant.R_ATTR_DIALOG_PREFERENCE_STYLE, defStyleRes: Int = defStyleAttr) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputNumberPreference.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputNumberPreference.kt index 116eaedbe..8a1f69879 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputNumberPreference.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputNumberPreference.kt @@ -26,7 +26,7 @@ import com.kunzisoft.keepass.R open class InputNumberPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.dialogPreferenceStyle, + defStyleAttr: Int = PreferenceConstant.R_ATTR_DIALOG_PREFERENCE_STYLE, defStyleRes: Int = defStyleAttr) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputSizePreference.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputSizePreference.kt index 39dee92e6..2030fc7c2 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputSizePreference.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputSizePreference.kt @@ -26,7 +26,7 @@ import com.kunzisoft.keepass.utils.DataByte open class InputSizePreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.dialogPreferenceStyle, + defStyleAttr: Int = PreferenceConstant.R_ATTR_DIALOG_PREFERENCE_STYLE, defStyleRes: Int = defStyleAttr) : InputNumberPreference(context, attrs, defStyleAttr, defStyleRes) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputTextPreference.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputTextPreference.kt index a8e18a750..b2bc8e3ce 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputTextPreference.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preference/InputTextPreference.kt @@ -27,7 +27,7 @@ import com.kunzisoft.keepass.R open class InputTextPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.dialogPreferenceStyle, + defStyleAttr: Int = PreferenceConstant.R_ATTR_DIALOG_PREFERENCE_STYLE, defStyleRes: Int = defStyleAttr) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) { diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preference/PreferenceConstant.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preference/PreferenceConstant.kt new file mode 100644 index 000000000..0ac354129 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preference/PreferenceConstant.kt @@ -0,0 +1,8 @@ +package com.kunzisoft.keepass.settings.preference + +class PreferenceConstant { + companion object { + const val R_ATTR_DIALOG_PREFERENCE_STYLE: Int = 16842897; + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/settings/preference/TextPreference.kt b/app/src/main/java/com/kunzisoft/keepass/settings/preference/TextPreference.kt index 8b204e0c0..b60e48d0a 100644 --- a/app/src/main/java/com/kunzisoft/keepass/settings/preference/TextPreference.kt +++ b/app/src/main/java/com/kunzisoft/keepass/settings/preference/TextPreference.kt @@ -27,7 +27,7 @@ import com.kunzisoft.keepass.R open class TextPreference @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = R.attr.dialogPreferenceStyle, + defStyleAttr: Int = PreferenceConstant.R_ATTR_DIALOG_PREFERENCE_STYLE, defStyleRes: Int = defStyleAttr) : DialogPreference(context, attrs, defStyleAttr, defStyleRes) { diff --git a/app/src/main/java/com/kunzisoft/keepass/view/SectionView.kt b/app/src/main/java/com/kunzisoft/keepass/view/SectionView.kt index 396b66835..26666ce6c 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/SectionView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/SectionView.kt @@ -30,7 +30,7 @@ import com.kunzisoft.keepass.R class SectionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, - defStyle: Int = R.attr.cardViewStyle) + defStyle: Int = com.google.android.material.R.attr.cardViewStyle) : CardView(context, attrs, defStyle) { private var containerSectionView = LinearLayout(context).apply { diff --git a/app/src/main/java/com/kunzisoft/keepass/view/TemplateAbstractView.kt b/app/src/main/java/com/kunzisoft/keepass/view/TemplateAbstractView.kt index dd336c843..d6c7fb1f6 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/TemplateAbstractView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/TemplateAbstractView.kt @@ -148,7 +148,7 @@ abstract class TemplateAbstractView< // Build each section template.sections.forEach { templateSection -> - val sectionView = SectionView(context, null, R.attr.cardViewStyle) + val sectionView = SectionView(context, null, com.google.android.material.R.attr.cardViewStyle) // Build each attribute templateSection.attributes.forEach { templateAttribute -> diff --git a/app/src/main/java/com/kunzisoft/keepass/view/ToolbarAction.kt b/app/src/main/java/com/kunzisoft/keepass/view/ToolbarAction.kt index 82f0fbfa1..975da93d3 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/ToolbarAction.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/ToolbarAction.kt @@ -47,7 +47,7 @@ class ToolbarAction @JvmOverloads constructor(context: Context, init { ContextCompat.getDrawable(context, R.drawable.ic_close_white_24dp)?.let { closeDrawable -> val typedValue = TypedValue() - context.theme.resolveAttribute(R.attr.colorOnSurface, typedValue, true) + context.theme.resolveAttribute(com.google.android.material.R.attr.colorOnSurface, typedValue, true) @ColorInt val colorControl = typedValue.data closeDrawable.colorFilter = PorterDuffColorFilter(colorControl, PorterDuff.Mode.SRC_ATOP) navigationIcon = closeDrawable diff --git a/app/src/main/java/com/kunzisoft/keepass/view/ToolbarSpecial.kt b/app/src/main/java/com/kunzisoft/keepass/view/ToolbarSpecial.kt index 8367fdea0..141490c47 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/ToolbarSpecial.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/ToolbarSpecial.kt @@ -38,7 +38,7 @@ class ToolbarSpecial @JvmOverloads constructor(context: Context, init { ContextCompat.getDrawable(context, R.drawable.ic_arrow_back_white_24dp)?.let { closeDrawable -> val typedValue = TypedValue() - context.theme.resolveAttribute(R.attr.colorOnSurface, typedValue, true) + context.theme.resolveAttribute(com.google.android.material.R.attr.colorOnSurface, typedValue, true) @ColorInt val colorOnSurface = typedValue.data closeDrawable.colorFilter = PorterDuffColorFilter(colorOnSurface, PorterDuff.Mode.SRC_ATOP) navigationIcon = closeDrawable diff --git a/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt b/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt index 90e9ff5b2..a329902f2 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/ViewUtil.kt @@ -117,7 +117,7 @@ fun TextView.customLink(listener: (View) -> Unit) { fun Snackbar.asError(): Snackbar { this.view.apply { setBackgroundColor(Color.RED) - findViewById(R.id.snackbar_text).setTextColor(Color.WHITE) + findViewById(com.google.android.material.R.id.snackbar_text).setTextColor(Color.WHITE) } return this } @@ -308,7 +308,7 @@ fun Activity.setTransparentNavigationBar(applyToStatusBar: Boolean = false, appl WindowCompat.setDecorFitsSystemWindows(window, false) window.navigationBarColor = ContextCompat.getColor(this, R.color.surface_selector) if (applyToStatusBar) { - obtainStyledAttributes(intArrayOf(R.attr.colorSurface)).apply { + obtainStyledAttributes(intArrayOf(com.google.android.material.R.attr.colorSurface)).apply { window.statusBarColor = getColor(0, Color.GRAY) recycle() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f4befb9e5..01bff8c47 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -733,4 +733,8 @@ Displays foreground and background colors for an entry Hide expired entries Expired entries are not shown + + Confirm passkey usage + for %1$s + Cancel \ No newline at end of file diff --git a/app/src/main/res/xml/provider.xml b/app/src/main/res/xml/provider.xml new file mode 100644 index 000000000..5dc307a8c --- /dev/null +++ b/app/src/main/res/xml/provider.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/build.gradle b/build.gradle index 80b4e9176..b736f33cb 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.8.20' + ext.kotlin_version = '2.0.0' ext.android_core_version = '1.10.1' ext.android_appcompat_version = '1.6.1' ext.android_material_version = '1.9.0' @@ -10,7 +10,7 @@ buildscript { google() } dependencies { - classpath 'com.android.tools.build:gradle:7.4.2' + classpath 'com.android.tools.build:gradle:8.4.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/crypto/build.gradle b/crypto/build.gradle index 20634e48f..ddab8d18a 100644 --- a/crypto/build.gradle +++ b/crypto/build.gradle @@ -9,7 +9,7 @@ android { ndkVersion "21.4.7075529" defaultConfig { - minSdkVersion 15 + minSdkVersion 19 targetSdkVersion 34 multiDexEnabled true @@ -40,7 +40,7 @@ android { dependencies { // Crypto - implementation 'org.bouncycastle:bcprov-jdk15on:1.70' + implementation 'org.bouncycastle:bcpkix-jdk18on:1.78.1' testImplementation "androidx.test:runner:$android_test_version" } diff --git a/crypto/src/main/java/com/kunzisoft/signature/Signature.kt b/crypto/src/main/java/com/kunzisoft/signature/Signature.kt new file mode 100644 index 000000000..ee94f44ca --- /dev/null +++ b/crypto/src/main/java/com/kunzisoft/signature/Signature.kt @@ -0,0 +1,43 @@ +package com.kunzisoft.signature + +import android.util.Log +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo +import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.io.StringReader +import java.security.PrivateKey +import java.security.Security +import java.security.Signature + +import org.bouncycastle.openssl.PEMParser +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter + +object Signature { + + init { + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) + Security.addProvider(BouncyCastleProvider()) + } + fun sign(privateKeyPem: String, message: ByteArray): ByteArray { + val privateKey = createPrivateKey(privateKeyPem) + val algorithmKey = privateKey.algorithm + val algorithmSignature = when (algorithmKey) { + "EC" -> "SHA256withECDSA" + "ECDSA" -> "SHA256withECDSA" + "RSA" -> "SHA256withRSA" + else -> "no signature algorithms known" + } + val sig = Signature.getInstance(algorithmSignature, BouncyCastleProvider.PROVIDER_NAME) + sig.initSign(privateKey) + sig.update(message) + return sig.sign() + } + + private fun createPrivateKey(privateKeyPem: String): PrivateKey { + val targetReader = StringReader(privateKeyPem); + val a = PEMParser(targetReader) + val privateKeyInfo = a.readObject() as PrivateKeyInfo + val privateKey = JcaPEMKeyConverter().getPrivateKey(privateKeyInfo) + return privateKey + } + +} \ No newline at end of file diff --git a/database/build.gradle b/database/build.gradle index b11df6359..5fd603a8f 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -6,7 +6,7 @@ android { compileSdkVersion 34 defaultConfig { - minSdkVersion 15 + minSdkVersion 19 targetSdk 34 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae04661ee..00def7772 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Sun Sep 08 17:39:21 CEST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/icon-pack/build.gradle b/icon-pack/build.gradle index bceaec7f5..fe907a19f 100644 --- a/icon-pack/build.gradle +++ b/icon-pack/build.gradle @@ -6,7 +6,7 @@ android { compileSdkVersion 34 defaultConfig { - minSdkVersion 14 + minSdkVersion 19 targetSdkVersion 34 } diff --git a/icon-pack/classic/build.gradle b/icon-pack/classic/build.gradle index c5262974d..6fcd807bc 100644 --- a/icon-pack/classic/build.gradle +++ b/icon-pack/classic/build.gradle @@ -5,7 +5,7 @@ android { compileSdkVersion 34 defaultConfig { - minSdkVersion 14 + minSdkVersion 19 targetSdkVersion 34 } diff --git a/icon-pack/material/build.gradle b/icon-pack/material/build.gradle index b4dfe5d79..b17b7ebee 100644 --- a/icon-pack/material/build.gradle +++ b/icon-pack/material/build.gradle @@ -5,7 +5,7 @@ android { compileSdkVersion 34 defaultConfig { - minSdkVersion 14 + minSdkVersion 19 targetSdkVersion 34 } From c907750446bf7b200af668f9691e76aef8f64ec0 Mon Sep 17 00:00:00 2001 From: cali Date: Sun, 13 Oct 2024 20:37:46 +0200 Subject: [PATCH 002/136] implement creation and update of passkeys --- ...kotlin-compiler-6565651273525833087.salive | 0 README.md | 23 +- app/src/main/AndroidManifest.xml | 32 +- .../activities/legacy/DatabaseActivity.kt | 16 +- .../CredentialProviderActivity.kt | 185 --------- .../KeePassDXCredentialProviderService.kt | 179 --------- .../MyAuthenticatorAssertionResponse.kt | 114 ------ .../keepass/credentialprovider/PasskeyUtil.kt | 93 ----- .../activity/CreatePasskeyActivity.kt | 281 +++++++++++++ .../activity/UsePasskeyActivity.kt | 236 +++++++++++ .../credentialprovider/data/Passkey.kt | 14 + .../PublicKeyCredentialCreationOptions.kt | 9 + .../data/PublicKeyCredentialRequestOptions.kt | 7 + .../KeePassDXCredentialProviderService.kt | 213 ++++++++++ .../util/AppRelyingPartyRelation.kt | 18 + .../credentialprovider/util/Base64Helper.kt | 20 + .../credentialprovider/util/DatabaseHelper.kt | 98 +++++ .../credentialprovider/util/IntentHelper.kt | 195 ++++++++++ .../credentialprovider/util/JsonHelper.kt | 251 ++++++++++++ .../credentialprovider/util/OriginHelper.kt | 22 ++ .../util/PasskeyConverter.kt | 120 ++++++ .../keepass/database/DatabaseTaskProvider.kt | 368 ++++++++++-------- app/src/main/res/values/strings.xml | 20 +- app/src/main/res/xml/provider.xml | 3 +- crypto/build.gradle | 8 +- .../com/kunzisoft/asymmetric/SignatureTest.kt | 102 +++++ .../com/kunzisoft/asymmetric/Signature.kt | 176 +++++++++ .../com/kunzisoft/random/KeePassDXRandom.kt | 21 + .../java/com/kunzisoft/signature/Signature.kt | 43 -- 29 files changed, 2060 insertions(+), 807 deletions(-) create mode 100644 .kotlin/sessions/kotlin-compiler-6565651273525833087.salive delete mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/CredentialProviderActivity.kt delete mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/KeePassDXCredentialProviderService.kt delete mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/MyAuthenticatorAssertionResponse.kt delete mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/PasskeyUtil.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/CreatePasskeyActivity.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/UsePasskeyActivity.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/Passkey.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialCreationOptions.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialRequestOptions.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/service/KeePassDXCredentialProviderService.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/AppRelyingPartyRelation.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/Base64Helper.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/DatabaseHelper.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/IntentHelper.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/JsonHelper.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/OriginHelper.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/PasskeyConverter.kt create mode 100644 crypto/src/androidTest/java/com/kunzisoft/asymmetric/SignatureTest.kt create mode 100644 crypto/src/main/java/com/kunzisoft/asymmetric/Signature.kt create mode 100644 crypto/src/main/java/com/kunzisoft/random/KeePassDXRandom.kt delete mode 100644 crypto/src/main/java/com/kunzisoft/signature/Signature.kt diff --git a/.kotlin/sessions/kotlin-compiler-6565651273525833087.salive b/.kotlin/sessions/kotlin-compiler-6565651273525833087.salive new file mode 100644 index 000000000..e69de29bb diff --git a/README.md b/README.md index 364fd3b59..bc85b74af 100644 --- a/README.md +++ b/README.md @@ -113,20 +113,29 @@ Other questions? You can read the [FAQ](https://github.com/Kunzisoft/KeePassDX/w ## Credential Provider -Use this version only for testing at your own risk. +Use this version only for testing at your own risk. Make a backup of your databases. ### requirements + - Android 14 or up - enable 3rd party passkeys in chrome. For detail see https://1password.community/discussion/comment/711037/#Comment_711037 - set KeepassDX in the Android setting Passwords & Accounts > Your Provider > Enable +- biometric authentication set up ### working -- sign in with ecdsa/rsa passkeys created by KeepassXC in Chrome. Tested with passkeys.io and webauthn.io. -### maybe working -- sign in with passkeys apps natively (without browser) +- sign in with ecdsa/rsa passkeys created by KeepassXC or KeepassDX in Chrome/Firefox. Tested with + passkeys.io and webauthn.io. +- create new passkeys with ecdsa/rsa in root group (compatible with KeepassXC) +- update existing passkeys ### not working -- create passkeys -- user credential provider with username/password -- open KeepassDX to unlock the database, if it is locked (currently a dummy entry with title unlock db is shown) \ No newline at end of file + +- support for username/password see provider.xml +- go back after unlocking a database, if all databases are locked +- support for native apps (implementation is included, but disable in AppRelyingPartyRelation to + prevent phishing) +- select the group, where the new passkeys are saved +- strings in non-english +- respect excludeCredentials +- other userVerification methode other than strong biometric \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5b881859b..db11248a6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,7 +44,7 @@ android:largeHeap="true" android:resizeableActivity="true" android:theme="@style/KeepassDXStyle.Night" - tools:targetApi="s" + tools:targetApi="tiramisu" android:enableOnBackInvokedCallback="true"> - + - - - - - + + + - + android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE" + tools:targetApi="upside_down_cake"> - + + android:resource="@xml/provider" /> handlePublicKeyCredOption(credOption, getRequest.callingAppInfo) - is GetPasswordOption -> handlePasswordOption(credOption) - else -> throw Exception("unknown type of credentialOption") - } - - Log.d(javaClass.simpleName, "onDatabaseRetrieved finished") - - } - private fun handlePublicKeyCredOption(publicKeyRequest: GetPublicKeyCredentialOption, callingAppInfo: CallingAppInfo) { - - val requestInfo = intent.getBundleExtra(KeePassDXCredentialProviderService.INTENT_EXTRA_KEY) - val nodeId = requestInfo?.getString(KeePassDXCredentialProviderService.NODE_ID_KEY) - - Log.d(javaClass.simpleName, "nodeId = $nodeId") - - if (mDatabase == null || nodeId == null) { - cleanUp() - return - } - val passkey = PasskeyUtil.searchPassKeyByNodeId(mDatabase!!, nodeId) - - - if (passkey == null) { - cleanUp() - return - } - Log.d(javaClass.simpleName, "passkey found") - - val credId = b64Decode(passkey.credId) - val privateKey = passkey.privateKeyPem - val uid = b64Decode(passkey.userHandle) - - val origin = appInfoToOrigin(callingAppInfo) - - Log.d(javaClass.simpleName, "origin = $origin") - val packageName = callingAppInfo.packageName - - val biometricPrompt = BiometricPrompt( - this, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - cleanUp() - } - - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - cleanUp() - } - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - validatePasskey( - publicKeyRequest.requestJson, - origin!!, - packageName, - uid, - passkey.username, - credId, - privateKey - ) - } - } - ) - - val title = getString(R.string.passkey_biometric_prompt_title) - val subtitle = getString(R.string.passkey_biometric_prompt_subtitle, origin) - val negativeButtonText = getString(R.string.passkey_biometric_prompt_negative_button_text) - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(title) - .setSubtitle(subtitle) - .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) - .setNegativeButtonText(negativeButtonText) - .build() - biometricPrompt.authenticate(promptInfo) - } - - private fun handlePasswordOption(passwordOption: GetPasswordOption) { - // TODO - } - - private fun appInfoToOrigin(callingAppInfo: CallingAppInfo): String? { - val privilegedAllowlist = assets.open("trustedPackages.json").bufferedReader().use { - it.readText() - } - return callingAppInfo.getOrigin(privilegedAllowlist)?.removeSuffix("/") - } - - -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/KeePassDXCredentialProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/KeePassDXCredentialProviderService.kt deleted file mode 100644 index eaaba0c35..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/KeePassDXCredentialProviderService.kt +++ /dev/null @@ -1,179 +0,0 @@ -package com.kunzisoft.keepass.credentialprovider; - -import android.app.PendingIntent -import android.content.Intent -import android.os.Bundle -import android.os.CancellationSignal; -import android.os.OutcomeReceiver; -import android.provider.ContactsContract.Directory.PACKAGE_NAME - -import androidx.annotation.RequiresApi -import androidx.credentials.exceptions.ClearCredentialException -import androidx.credentials.exceptions.CreateCredentialException -import androidx.credentials.exceptions.GetCredentialException; -import androidx.credentials.exceptions.GetCredentialUnknownException -import androidx.credentials.provider.BeginCreateCredentialRequest -import androidx.credentials.provider.BeginCreateCredentialResponse -import androidx.credentials.provider.BeginGetCredentialRequest -import androidx.credentials.provider.BeginGetCredentialResponse; -import androidx.credentials.provider.BeginGetPasswordOption -import androidx.credentials.provider.BeginGetPublicKeyCredentialOption -import androidx.credentials.provider.CallingAppInfo -import androidx.credentials.provider.CredentialEntry -import androidx.credentials.provider.CredentialProviderService -import androidx.credentials.provider.ProviderClearCredentialStateRequest -import androidx.credentials.provider.PublicKeyCredentialEntry -import org.json.JSONObject - -import android.util.Log -import com.kunzisoft.keepass.database.DatabaseTaskProvider -import com.kunzisoft.keepass.database.element.Database -import com.kunzisoft.keepass.database.search.SearchHelper -import com.kunzisoft.keepass.database.search.SearchParameters - -@RequiresApi(value = 34) -class KeePassDXCredentialProviderService : CredentialProviderService() { - - private var mDatabaseTaskProvider: DatabaseTaskProvider? = null - private var mDatabase: Database? = null - - override fun onCreate() { - super.onCreate() - - mDatabaseTaskProvider = DatabaseTaskProvider(this) - mDatabaseTaskProvider?.registerProgressTask() - mDatabaseTaskProvider?.onDatabaseRetrieved = { database -> - this.mDatabase = database - } - } - - override fun onDestroy() { - mDatabaseTaskProvider?.unregisterProgressTask() - super.onDestroy() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - return super.onStartCommand(intent, flags, startId) - } - - override fun onBeginCreateCredentialRequest(request: BeginCreateCredentialRequest, cancellationSignal: CancellationSignal, callback: OutcomeReceiver) { - TODO("Not yet implemented") - - } - - override fun onBeginGetCredentialRequest( - request: BeginGetCredentialRequest, - cancellationSignal: CancellationSignal, - callback: OutcomeReceiver, - ) { - /* - val unlockEntryTitle = "Authenticate to continue" - if (isAppLocked()) { - callback.onResult(BeginGetCredentialResponse( - authenticationActions = mutableListOf(AuthenticationAction( - unlockEntryTitle, createUnlockPendingIntent()) - ) - ) - ) - return - } - */ - try { - val response = processGetCredentialsRequest(request) - callback.onResult(response) - } catch (e: GetCredentialException) { - callback.onError(GetCredentialUnknownException()) - } - } - - companion object { - private const val GET_PASSKEY_INTENT_ACTION = "com.kunzisoft.keepass.credentialprovider.GET_PASSKEY" - private const val GET_PASSWORD_INTENT_ACTION = "com.kunzisoft.keepass.credentialprovider.GET_PASSWORD" - - const val NODE_ID_KEY = "nodeId" - const val INTENT_EXTRA_KEY = "CREDENTIAL_DATA" - } - - private fun processGetCredentialsRequest( - request: BeginGetCredentialRequest - ): BeginGetCredentialResponse { - - val callingAppInfo = request.callingAppInfo ?: throw Exception("callingAppInfo is null") - val credentialEntries: MutableList = mutableListOf() - - for (option in request.beginGetCredentialOptions) { - when (option) { - is BeginGetPasswordOption -> { - // TODO - } - is BeginGetPublicKeyCredentialOption -> { - credentialEntries.addAll( - populatePasskeyData(callingAppInfo, option) - ) - } else -> { - Log.d(javaClass.simpleName,"Request not supported") - } - } - } - return BeginGetCredentialResponse(credentialEntries) - } - - private fun populatePasskeyData(callingAppInfo: CallingAppInfo, option: BeginGetPublicKeyCredentialOption): List { - - val json = JSONObject(option.requestJson) - - val relyingPartyId = json.optString("rpId", "") - - val passkeys = getCredentialsFromDb(relyingPartyId) - - val passkeyEntries: MutableList = mutableListOf() - for (passkey in passkeys) { - val data = Bundle() - data.putString(NODE_ID_KEY, passkey.nodeId) - passkeyEntries.add( - PublicKeyCredentialEntry( - context = applicationContext, - username = passkey.username, - pendingIntent = createNewPendingIntent( - GET_PASSKEY_INTENT_ACTION, - data - ), - beginGetPublicKeyCredentialOption = option, - displayName = passkey.displayName, - lastUsedTime = passkey.lastUsedTime, - isAutoSelectAllowed = false - ) - ) - } - return passkeyEntries - } - - private fun getCredentialsFromDb(relyingPartyId: String) : List { - if (mDatabase == null) { - // TODO make sure that the database is open - val dummyPassKey = PasskeyUtil.Passkey("", "unknown", "unlock db", "", "", "", "", null) - return listOf(dummyPassKey) - } - val passkeys = PasskeyUtil.searchPasskeys(mDatabase!!) - val passkeysMatching = passkeys.filter { p -> p.relyingParty == relyingPartyId } - return passkeysMatching - } - - - private fun createNewPendingIntent(action: String, extra: Bundle? = null): PendingIntent { - val intent = Intent(action).setPackage(PACKAGE_NAME).setClass(applicationContext, CredentialProviderActivity::class.java) - if (extra != null) { - intent.putExtra(INTENT_EXTRA_KEY, extra) - } - val requestCode = 42 // not used - return PendingIntent.getActivity( - applicationContext, requestCode, intent, - (PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) - ) - } - - override fun onClearCredentialStateRequest(request: ProviderClearCredentialStateRequest, cancellationSignal: CancellationSignal, callback: OutcomeReceiver) { - // nothing to do - } - -} diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/MyAuthenticatorAssertionResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/MyAuthenticatorAssertionResponse.kt deleted file mode 100644 index d8536a26f..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/MyAuthenticatorAssertionResponse.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* -* Copyright 2023 The Android Open Source Project -* -* Licensed under the Apache License, Version 2.0 (the "License"); -* you may not use this file except in compliance with the License. -* You may obtain a copy of the License at -* -* http://www.apache.org/licenses/LICENSE-2.0 -* -* Unless required by applicable law or agreed to in writing, software -* distributed under the License is distributed on an "AS IS" BASIS, -* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -* See the License for the specific language governing permissions and -* limitations under the License. -*/ - -package androidx.credentials.webauthn - -import android.annotation.SuppressLint -import android.util.Log -import java.security.MessageDigest -import org.json.JSONObject -import org.apache.commons.codec.binary.Base64 - -@SuppressLint("RestrictedApi") -class MyAuthenticatorAssertionResponse( - private val requestOptions: PublicKeyCredentialRequestOptions, - private val credentialId: ByteArray, - private val origin: String, - private val up: Boolean, - private val uv: Boolean, - private val be: Boolean, - private val bs: Boolean, - private var userHandle: ByteArray, - private val packageName: String? = null, - private val clientDataHash: ByteArray? = null, -) : AuthenticatorResponse { - override var clientJson = JSONObject() - var authenticatorData: ByteArray - var signature: ByteArray = byteArrayOf() - - init { - clientJson.put("type", "webauthn.get") - clientJson.put("challenge", b64Encode(requestOptions.challenge)) - clientJson.put("origin", origin) - clientJson.put("crossOrigin", false) - if (packageName != null) { - clientJson.put("androidPackageName", packageName) - } - - authenticatorData = defaultAuthenticatorData() - } - - fun defaultAuthenticatorData(): ByteArray { - val md = MessageDigest.getInstance("SHA-256") - val rpHash = md.digest(requestOptions.rpId.toByteArray()) - var flags: Int = 0 - if (up) { - flags = flags or 0x01 - } - if (uv) { - flags = flags or 0x04 - } - if (be) { - flags = flags or 0x08 - } - if (bs) { - flags = flags or 0x10 - } - val ret = rpHash + byteArrayOf(flags.toByte()) + byteArrayOf(0, 0, 0, 0) - return ret - } - - fun dataToSign(): ByteArray { - val md = MessageDigest.getInstance("SHA-256") - var hash: ByteArray - if (clientDataHash != null) { - hash = clientDataHash - } else { - hash = md.digest(temp().toByteArray()) - } - - return authenticatorData + hash - } - - override fun json(): JSONObject { - - val clientJsonTemp = temp() - Log.w("", clientJsonTemp) - val clientData = clientJsonTemp.toByteArray() - val response = JSONObject() - if (clientDataHash == null) { - response.put("clientDataJSON", b64Encode(clientData)) - } - response.put("authenticatorData", b64Encode(authenticatorData)) - response.put("signature", b64Encode(signature)) - response.put("userHandle", b64Encode(userHandle)) - return response - } - - fun temp(): String { - val clientJsonTemp = clientJson.toString() - val clientJsonGood = clientJsonTemp.replace("\\/", "/") - return clientJsonGood - } - - private fun b64Decode(encodedString: String?): ByteArray { - return Base64.decodeBase64(encodedString) - } - - private fun b64Encode(binData: ByteArray?): String { - return Base64.encodeBase64URLSafeString(binData) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/PasskeyUtil.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/PasskeyUtil.kt deleted file mode 100644 index c1b578c01..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/PasskeyUtil.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.kunzisoft.keepass.credentialprovider - -import android.os.Build -import com.kunzisoft.keepass.database.element.Database -import com.kunzisoft.keepass.database.element.Entry -import com.kunzisoft.keepass.database.element.node.NodeIdUUID -import com.kunzisoft.keepass.database.search.SearchHelper -import com.kunzisoft.keepass.database.search.SearchParameters -import com.kunzisoft.keepass.utils.UuidUtil -import java.time.Instant - -class PasskeyUtil { - - data class Passkey(val nodeId: String, val username: String, val displayName: String, val privateKeyPem: String, val credId: String, val userHandle: String, val relyingParty: String, val lastUsedTime: Instant?) - - companion object { - - const val PASSKEY_TAG = "Passkey" - fun convertEntryToPasskey(entry: Entry): Passkey? { - if (!entry.tags.toList().contains(PASSKEY_TAG)) { - return null - } - - val nodeId = UuidUtil.toHexString(entry.nodeId.id)!! - - val displayName = entry.getVisualTitle() - val lastUsedTime = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - entry.lastAccessTime.date.toInstant() - } else { - null - } - var username = "" - var privateKeyPem = "" - var credId = "" - var userHandle = "" - var relyingParty = "" - - for (field in entry.getExtraFields()) { - val fieldName = field.name - - // field names from KeypassXC are used - if (fieldName == "KPEX_PASSKEY_USERNAME") { - username = field.protectedValue.stringValue - } else if (field.name == "KPEX_PASSKEY_PRIVATE_KEY_PEM") { - privateKeyPem = field.protectedValue.stringValue - } else if (field.name == "KPEX_PASSKEY_CREDENTIAL_ID") { - credId = field.protectedValue.stringValue - } else if (field.name == "KPEX_PASSKEY_USER_HANDLE") { - userHandle = field.protectedValue.stringValue - } else if (field.name == "KPEX_PASSKEY_RELYING_PARTY") { - relyingParty = field.protectedValue.stringValue - } - // KPEX_PASSKEY_RELYING_PARTY - } - return Passkey(nodeId, username, displayName, privateKeyPem, credId, userHandle, relyingParty, lastUsedTime) - } - - fun convertEntriesListToPasskeys(entries: List): List { - return entries.mapNotNull { e -> convertEntryToPasskey(e) } - } - - fun searchPasskeys(database: Database): List { - val searchHelper = SearchHelper() - val searchParameters = SearchParameters().apply { - searchQuery = PASSKEY_TAG - searchInTitles = false - searchInUsernames = false - searchInPasswords = false - searchInUrls = false - searchInNotes = false - searchInOTP = false - searchInOther = false - searchInUUIDs = false - searchInTags = true - searchInCurrentGroup = false - searchInSearchableGroup = false - searchInRecycleBin = false - searchInTemplates = false - } - val searchResult = searchHelper.createVirtualGroupWithSearchResult(database, searchParameters, null, Int.MAX_VALUE) - ?: return emptyList() - - return convertEntriesListToPasskeys(searchResult.getChildEntries()) - } - - fun searchPassKeyByNodeId(database: Database, nodeId: String): Passkey? { - val uuidToSearch = UuidUtil.fromHexString(nodeId)!! - val nodeIdUUIDToSearch = NodeIdUUID(uuidToSearch) - val entry = database.getEntryById(nodeIdUUIDToSearch)!! - return convertEntryToPasskey(entry) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/CreatePasskeyActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/CreatePasskeyActivity.kt new file mode 100644 index 000000000..5087a5c3e --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/CreatePasskeyActivity.kt @@ -0,0 +1,281 @@ +package com.kunzisoft.keepass.credentialprovider.activity + +import android.app.Activity +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.PendingIntentHandler +import com.kunzisoft.asymmetric.Signature +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.activities.legacy.DatabaseActivity +import com.kunzisoft.keepass.credentialprovider.data.Passkey +import com.kunzisoft.keepass.credentialprovider.data.PublicKeyCredentialCreationOptions +import com.kunzisoft.keepass.credentialprovider.util.AppRelyingPartyRelation +import com.kunzisoft.keepass.credentialprovider.util.Base64Helper +import com.kunzisoft.keepass.credentialprovider.util.DatabaseHelper +import com.kunzisoft.keepass.credentialprovider.util.IntentHelper +import com.kunzisoft.keepass.credentialprovider.util.JsonHelper +import com.kunzisoft.keepass.credentialprovider.util.OriginHelper +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.database.DatabaseTaskProvider +import com.kunzisoft.random.KeePassDXRandom + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class CreatePasskeyActivity : DatabaseActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + Log.d(javaClass.simpleName, "onCreate called") + super.onCreate(savedInstanceState) + } + + override fun onDatabaseRetrieved(database: ContextualDatabase?) { + Log.d(javaClass.simpleName, "onDatabaseRetrieved called") + super.onDatabaseRetrieved(database) + + try { + if (database == null) { + throw CreateCredentialUnknownException("retrievedDatabase is null, maybe database is locked") + } + + if (intent == null) { + throw CreateCredentialUnknownException("intent is null") + } + + if (mDatabaseTaskProvider == null) { + throw CreateCredentialUnknownException("mDatabaseTaskProvider is null") + } + + createPasskeyAfterPrompt(database, mDatabaseTaskProvider!!, intent) + } catch (e: CreateCredentialUnknownException) { + Log.e(this::class.java.simpleName, "CreateCredentialUnknownException was thrown", e) + setResult(RESULT_CANCELED) + finish() + } catch (e: Exception) { + Log.e(this::class.java.simpleName, "other exception was thrown", e) + setResult(RESULT_CANCELED) + finish() + } + + } + + private fun createPasskeyAfterPrompt( + database: ContextualDatabase, + databaseTaskProvider: DatabaseTaskProvider, + intent: Intent + ) { + + val request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) + ?: throw CreateCredentialUnknownException("could not retrieve request from intent") + + if (request.callingRequest !is CreatePublicKeyCredentialRequest) { + throw CreateCredentialUnknownException("callingRequest is of wrong type: ${request.callingRequest.type}") + } + val publicKeyRequest = request.callingRequest as CreatePublicKeyCredentialRequest + + val creationOptions = JsonHelper.parseJsonToCreateOptions(publicKeyRequest.requestJson) + + val relyingParty = creationOptions.relyingParty + + val biometricPrompt = BiometricPrompt( + this, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError( + errorCode: Int, errString: CharSequence + ) { + super.onAuthenticationError(errorCode, errString) + throw CreateCredentialUnknownException("authentication error: errorCode = $errorCode, errString = $errString") + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + throw CreateCredentialUnknownException("authentication failed") + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + createPasskey(database, databaseTaskProvider, intent, creationOptions) + } + } + ) + + val title = getString(R.string.passkey_creation_biometric_prompt_title) + val subtitle = + getString( + R.string.passkey_creation_biometric_prompt_subtitle, + relyingParty + ) + val negativeButtonText = + getString(R.string.passkey_creation_biometric_prompt_negative_button_text) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .setNegativeButtonText(negativeButtonText) + .build() + biometricPrompt.authenticate(promptInfo) + } + + private fun createPasskey( + database: ContextualDatabase, + databaseTaskProvider: DatabaseTaskProvider, + intent: Intent, + creationOptions: PublicKeyCredentialCreationOptions + ) { + val request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) + + val nodeId = IntentHelper.getVerifiedNodeId(intent) + ?: throw CreateCredentialUnknownException("could not get verified nodeId from intent") + + val callingAppInfo = request!!.callingAppInfo + val relyingParty = creationOptions.relyingParty + val challenge = creationOptions.challenge + val keyTypeIdList = creationOptions.keyTypeIdList + val webOrigin = OriginHelper.getWebOrigin(callingAppInfo, assets) + val apkSigningCertificate = + callingAppInfo.signingInfo.apkContentsSigners.getOrNull(0)?.toByteArray() + + createPasskeyWithParameters( + relyingParty, + creationOptions.username, + creationOptions.userId, + database, + databaseTaskProvider, + keyTypeIdList, + challenge, + webOrigin, + apkSigningCertificate, + nodeId + ) + } + + private fun createPasskeyWithParameters( + relyingParty: String, + username: String, + userHandle: ByteArray, + database: ContextualDatabase, + databaseTaskProvider: DatabaseTaskProvider, + keyTypeIdList: List, + challenge: ByteArray, + webOrigin: String?, + apkSigningCertificate: ByteArray?, + nodeId: String + ) { + + val isPrivilegedApp = + (webOrigin != null && webOrigin == OriginHelper.DEFAULT_PROTOCOL + relyingParty) + Log.d(this::class.java.simpleName, "isPrivilegedApp = $isPrivilegedApp") + + if (!isPrivilegedApp) { + val isValid = + AppRelyingPartyRelation.isRelationValid(relyingParty, apkSigningCertificate) + if (!isValid) { + throw CreateCredentialUnknownException( + "could not verify relation between app " + + "and relyingParty $relyingParty" + ) + } + } + + val credentialId = KeePassDXRandom.generateCredentialId() + + val (keyPair, keyTypeId) = Signature.generateKeyPair(keyTypeIdList) + ?: throw CreateCredentialUnknownException("no known public key type found") + val privateKeyPem = Signature.convertPrivateKeyToPem(keyPair.private) + + if (IntentHelper.isPlaceholderNodeId(nodeId)) { + // create new entry in database + + val displayName = "$relyingParty (Passkey)" + val newPasskey = Passkey( + nodeId = "", // created by the database + username = username, + displayName = displayName, + privateKeyPem = privateKeyPem, + credId = Base64Helper.b64Encode(credentialId), + userHandle = Base64Helper.b64Encode(userHandle), + relyingParty = relyingParty, + databaseEntry = null + ) + + DatabaseHelper.saveNewEntry(database, databaseTaskProvider, newPasskey) + } else { + // update an existing entry in database + val oldPasskey = DatabaseHelper.searchPassKeyByNodeId(database, nodeId) + ?: throw GetCredentialUnknownException("no passkey with nodeId $nodeId found") + + val updatedPasskey = Passkey( + nodeId = "", // unchanged + username = username, + displayName = oldPasskey.displayName, + privateKeyPem = privateKeyPem, + credId = Base64Helper.b64Encode(credentialId), + userHandle = Base64Helper.b64Encode(userHandle), + relyingParty = relyingParty, + databaseEntry = oldPasskey.databaseEntry + ) + + DatabaseHelper.updateEntry(database, databaseTaskProvider, updatedPasskey) + } + + val publicKeyEncoded = Signature.convertPublicKey(keyPair.public, keyTypeId) + + val publicKeyMap = Signature.convertPublicKeyToMap(keyPair.public, keyTypeId) + val publicKeyCbor = JsonHelper.generateCborFromMap(publicKeyMap!!) + + val authData = JsonHelper.generateAuthDataForCreate( + userPresent = true, + userVerified = true, + backupEligibility = true, + backupState = true, + rpId = relyingParty.toByteArray(), + credentialId = credentialId, + credentialPublicKey = publicKeyCbor + ) + + val attestationObject = JsonHelper.generateAttestationObject(authData) + + val clientJson: String + if (isPrivilegedApp) { + clientJson = JsonHelper.generateClientDataJsonPrivileged() + } else { + val origin = OriginHelper.DEFAULT_PROTOCOL + relyingParty + clientJson = JsonHelper.generateClientDataJsonNonPrivileged( + challenge, + origin, + packageName, + isCrossOriginAdded = true, + isGet = false + ) + } + + val responseJson = JsonHelper.createAuthenticatorAttestationResponseJSON( + credentialId, + clientJson, + attestationObject, + publicKeyEncoded!!, + authData, + keyTypeId + ) + + // log only the length to prevent logging sensitive information + Log.d(javaClass.simpleName, "responseJson with length ${responseJson.length} created") + val createPublicKeyCredResponse = CreatePublicKeyCredentialResponse(responseJson) + + val resultOfActivity = Intent() + + PendingIntentHandler.setCreateCredentialResponse( + resultOfActivity, createPublicKeyCredResponse + ) + setResult(Activity.RESULT_OK, resultOfActivity) + finish() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/UsePasskeyActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/UsePasskeyActivity.kt new file mode 100644 index 000000000..5f915c77e --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/UsePasskeyActivity.kt @@ -0,0 +1,236 @@ +package com.kunzisoft.keepass.credentialprovider.activity + +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.PendingIntentHandler +import com.kunzisoft.asymmetric.Signature +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.activities.legacy.DatabaseActivity +import com.kunzisoft.keepass.credentialprovider.data.Passkey +import com.kunzisoft.keepass.credentialprovider.util.AppRelyingPartyRelation +import com.kunzisoft.keepass.credentialprovider.util.Base64Helper +import com.kunzisoft.keepass.credentialprovider.util.DatabaseHelper +import com.kunzisoft.keepass.credentialprovider.util.IntentHelper +import com.kunzisoft.keepass.credentialprovider.util.JsonHelper +import com.kunzisoft.keepass.credentialprovider.util.OriginHelper +import com.kunzisoft.keepass.credentialprovider.util.OriginHelper.Companion.DEFAULT_PROTOCOL +import com.kunzisoft.keepass.database.ContextualDatabase + + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class UsePasskeyActivity : DatabaseActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + Log.d(javaClass.simpleName, "onCreate called") + super.onCreate(savedInstanceState) + } + + + override fun onDatabaseRetrieved(database: ContextualDatabase?) { + Log.d(javaClass.simpleName, "onDatabaseRetrieved called") + super.onDatabaseRetrieved(database) + + try { + if (database == null) { + throw CreateCredentialUnknownException("retrievedDatabase is null, maybe database is locked") + } + + if (intent == null) { + throw CreateCredentialUnknownException("intent is null") + } + usePasskeyAfterPrompt(database, intent) + } catch (e: CreateCredentialUnknownException) { + Log.e(this::class.java.simpleName, "CreateCredentialUnknownException was thrown", e) + setResult(RESULT_CANCELED) + finish() + } catch (e: Exception) { + Log.e(this::class.java.simpleName, "other exception was thrown", e) + setResult(RESULT_CANCELED) + finish() + } + } + + private fun usePasskeyAfterPrompt( + database: ContextualDatabase, + intent: Intent + ) { + val request = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) + ?: throw CreateCredentialUnknownException("could not retrieve request from intent") + + if (request.credentialOptions.size != 1) { + throw GetCredentialUnknownException("not exact one credentialOption") + } + + if (request.credentialOptions[0] !is GetPublicKeyCredentialOption) { + throw CreateCredentialUnknownException("credentialOptions is of wrong type: ${request.credentialOptions[0]}") + } + + val credentialOption = request.credentialOptions[0] as GetPublicKeyCredentialOption + val clientDataHash = credentialOption.clientDataHash + + val requestOptions = JsonHelper.parseJsonToRequestOptions(credentialOption.requestJson) + + val relyingParty = requestOptions.relyingParty + val challenge = Base64Helper.b64Decode(requestOptions.challengeString) + val packageName = request.callingAppInfo.packageName + val webOrigin = OriginHelper.getWebOrigin(request.callingAppInfo, assets) + + val isPrivilegedApp = + (webOrigin != null && webOrigin == DEFAULT_PROTOCOL + relyingParty && clientDataHash != null) + + Log.d(javaClass.simpleName, "isPrivilegedApp = $isPrivilegedApp") + + if (!isPrivilegedApp) { + val apkSigners = request.callingAppInfo.signingInfo.apkContentsSigners + val apkSigningCertificate = apkSigners.getOrNull(0)?.toByteArray() + val isValid = + AppRelyingPartyRelation.isRelationValid(relyingParty, apkSigningCertificate) + if (!isValid) { + throw CreateCredentialUnknownException( + "could not verify relation between app " + + "and relyingParty $relyingParty" + ) + } + } + + val nodeId = IntentHelper.getVerifiedNodeId(intent) + ?: throw GetCredentialUnknownException("could not get verified nodeId from intent") + + val passkey = DatabaseHelper.searchPassKeyByNodeId(database, nodeId) + ?: throw GetCredentialUnknownException("no passkey with nodeId $nodeId found") + + usePasskeyAfterPromptWithParameters( + relyingParty, + packageName, + clientDataHash, + isPrivilegedApp, + challenge, + passkey + ) + } + + private fun usePasskeyAfterPromptWithParameters( + relyingParty: String, + packageName: String, + clientDataHash: ByteArray?, + isPrivilegedApp: Boolean, + challenge: ByteArray, + passkey: Passkey + ) { + val biometricPrompt = BiometricPrompt( + this, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + throw GetCredentialUnknownException("authentication error: errorCode = $errorCode, errString = $errString") + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + throw GetCredentialUnknownException("authentication failed") + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + createResponse( + relyingParty, + packageName, + clientDataHash, + isPrivilegedApp, + challenge, + passkey + ) + } + } + ) + + val title = getString(R.string.passkey_usage_biometric_prompt_title) + val subtitle = getString(R.string.passkey_usage_biometric_prompt_subtitle, relyingParty) + val negativeButtonText = + getString(R.string.passkey_usage_biometric_prompt_negative_button_text) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .setNegativeButtonText(negativeButtonText) + .build() + biometricPrompt.authenticate(promptInfo) + + + } + + private fun createResponse( + relyingParty: String, + packageName: String, + clientDataHash: ByteArray?, + isPrivilegedApp: Boolean, + challenge: ByteArray, + passkey: Passkey + ) { + + // https://www.w3.org/TR/webauthn-3/#authdata-flags + val userPresent = true + val userVerified = true + val backupEligibility = true + val backupState = true + + val authenticatorData = JsonHelper.generateAuthDataForUsage( + relyingParty.toByteArray(), + userPresent, + userVerified, + backupEligibility, + backupState + ) + + val clientDataJson: String + val dataToSign: ByteArray + if (isPrivilegedApp) { + clientDataJson = JsonHelper.generateClientDataJsonPrivileged() + dataToSign = + JsonHelper.generateDataToSignPrivileged(clientDataHash!!, authenticatorData) + } else { + val origin = DEFAULT_PROTOCOL + relyingParty + clientDataJson = JsonHelper.generateClientDataJsonNonPrivileged( + challenge, + origin, + packageName, + isGet = true, + isCrossOriginAdded = false + ) + dataToSign = + JsonHelper.generateDataTosSignNonPrivileged(clientDataJson, authenticatorData) + } + + val signature = Signature.sign(passkey.privateKeyPem, dataToSign) + ?: throw GetCredentialUnknownException("signing failed") + + val getCredentialResponse = + JsonHelper.generateGetCredentialResponse( + clientDataJson.toByteArray(), + authenticatorData, + signature, + passkey.userHandle, + passkey.credId + ) + + val result = Intent() + val passkeyCredential = PublicKeyCredential(getCredentialResponse) + PendingIntentHandler.setGetCredentialResponse( + result, GetCredentialResponse(passkeyCredential) + ) + setResult(RESULT_OK, result) + finish() + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/Passkey.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/Passkey.kt new file mode 100644 index 000000000..dfaadd0f0 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/Passkey.kt @@ -0,0 +1,14 @@ +package com.kunzisoft.keepass.credentialprovider.data + +import com.kunzisoft.keepass.database.element.Entry + +data class Passkey( + val nodeId: String, + val username: String, + val displayName: String, + val privateKeyPem: String, + val credId: String, + val userHandle: String, + val relyingParty: String, + val databaseEntry: Entry? +) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialCreationOptions.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialCreationOptions.kt new file mode 100644 index 000000000..187f1a054 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialCreationOptions.kt @@ -0,0 +1,9 @@ +package com.kunzisoft.keepass.credentialprovider.data + +data class PublicKeyCredentialCreationOptions( + val relyingParty: String, + val challenge: ByteArray, + val username: String, + val userId: ByteArray, + val keyTypeIdList: List +) \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialRequestOptions.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialRequestOptions.kt new file mode 100644 index 000000000..5a740d5d4 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialRequestOptions.kt @@ -0,0 +1,7 @@ +package com.kunzisoft.keepass.credentialprovider.data + +data class PublicKeyCredentialRequestOptions( + val relyingParty: String, + val challengeString: String +) { +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/service/KeePassDXCredentialProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/service/KeePassDXCredentialProviderService.kt new file mode 100644 index 000000000..0ffd0e661 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/service/KeePassDXCredentialProviderService.kt @@ -0,0 +1,213 @@ +package com.kunzisoft.keepass.credentialprovider.service + +import android.os.Build +import android.os.CancellationSignal +import android.os.OutcomeReceiver +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.credentials.exceptions.ClearCredentialException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.BeginCreateCredentialRequest +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest +import androidx.credentials.provider.BeginGetCredentialRequest +import androidx.credentials.provider.BeginGetCredentialResponse +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.CreateEntry +import androidx.credentials.provider.CredentialEntry +import androidx.credentials.provider.CredentialProviderService +import androidx.credentials.provider.ProviderClearCredentialStateRequest +import androidx.credentials.provider.PublicKeyCredentialEntry +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.credentialprovider.data.Passkey +import com.kunzisoft.keepass.credentialprovider.util.DatabaseHelper +import com.kunzisoft.keepass.credentialprovider.util.IntentHelper +import com.kunzisoft.keepass.credentialprovider.util.JsonHelper +import com.kunzisoft.keepass.database.DatabaseTaskProvider +import com.kunzisoft.keepass.database.element.Database +import java.time.Instant + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class KeePassDXCredentialProviderService : CredentialProviderService() { + + private var mDatabaseTaskProvider: DatabaseTaskProvider? = null + private var mDatabase: Database? = null + + override fun onCreate() { + super.onCreate() + + mDatabaseTaskProvider = DatabaseTaskProvider(this) + mDatabaseTaskProvider?.registerProgressTask() + mDatabaseTaskProvider?.onDatabaseRetrieved = { database -> + this.mDatabase = database + } + } + + override fun onDestroy() { + mDatabaseTaskProvider?.unregisterProgressTask() + super.onDestroy() + } + + override fun onBeginCreateCredentialRequest( + request: BeginCreateCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + Log.d(javaClass.simpleName, "onBeginCreateCredentialRequest called") + val response: BeginCreateCredentialResponse? = processCreateCredentialRequest(request) + if (response != null) { + callback.onResult(response) + } else { + callback.onError(CreateCredentialUnknownException()) + } + } + + private fun processCreateCredentialRequest(request: BeginCreateCredentialRequest): BeginCreateCredentialResponse? { + when (request) { + is BeginCreatePublicKeyCredentialRequest -> { + // Request is passkey type + return handleCreatePasskeyQuery(request) + } + } + // request type not supported + Log.w(javaClass.simpleName, "unknown type of BeginCreateCredentialRequest") + return null + } + + private fun handleCreatePasskeyQuery(request: BeginCreatePublicKeyCredentialRequest): BeginCreateCredentialResponse { + if (mDatabase == null) { + // database is locked, a dummy entry is shown. + val messageToUnlockDatabase = getString(R.string.passkey_usage_unlock_database_message) + val dummyEntryList = listOf( + CreateEntry( + getString(R.string.passkey_unknown_username), + IntentHelper.generateUnlockPendingIntent(applicationContext), + messageToUnlockDatabase + ) + ) + return BeginCreateCredentialResponse(dummyEntryList) + } + + val createEntries: MutableList = mutableListOf() + val accountName = mDatabase!!.name + val descriptionNewEntry = getString(R.string.passkey_creation_description) + val createPendingIntentNewEntry = + IntentHelper.generateCreatePendingIntent(applicationContext)!! + createEntries.add( + CreateEntry( + accountName, + createPendingIntentNewEntry, + descriptionNewEntry + ) + ) + + val relyingParty = JsonHelper.parseJsonToCreateOptions(request.requestJson).relyingParty + val passkeyList = getCredentialsFromDb(relyingParty, mDatabase!!) + for (passkey in passkeyList) { + val createPendingIntent = + IntentHelper.generateCreatePendingIntent(applicationContext, passkey.nodeId)!! + val description = getString(R.string.passkey_update_description, passkey.displayName) + createEntries.add( + CreateEntry( + accountName, + createPendingIntent, + description + ) + ) + } + + return BeginCreateCredentialResponse(createEntries) + } + + override fun onBeginGetCredentialRequest( + request: BeginGetCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + Log.d(javaClass.simpleName, "onBeginGetCredentialRequest called") + val response = processGetCredentialsRequest(request) + if (response != null) { + callback.onResult(response) + } else { + callback.onError(GetCredentialUnknownException()) + } + } + + private fun processGetCredentialsRequest(request: BeginGetCredentialRequest): BeginGetCredentialResponse? { + val credentialEntries: MutableList = mutableListOf() + + for (option in request.beginGetCredentialOptions) { + when (option) { + is BeginGetPublicKeyCredentialOption -> { + credentialEntries.addAll( + populatePasskeyData(option) + ) + return BeginGetCredentialResponse(credentialEntries) + } + } + } + Log.w(javaClass.simpleName, "unknown beginGetCredentialOption") + return null + } + + private fun populatePasskeyData(option: BeginGetPublicKeyCredentialOption): List { + + val relyingParty = JsonHelper.parseJsonToRequestOptions(option.requestJson).relyingParty + if (relyingParty.isBlank()) { + throw CreateCredentialUnknownException("relying party id is null or blank") + } + + if (mDatabase == null) { + val unknownUsername = getString(R.string.passkey_unknown_username) + val messageToUnlockDatabase = getString(R.string.passkey_usage_unlock_database_message) + val unlockPendingIntent = IntentHelper.generateUnlockPendingIntent(applicationContext) + val entry = PublicKeyCredentialEntry( + context = applicationContext, + username = unknownUsername, + pendingIntent = unlockPendingIntent, + beginGetPublicKeyCredentialOption = option, + displayName = messageToUnlockDatabase, + lastUsedTime = Instant.now(), + isAutoSelectAllowed = true + ) + return listOf(entry) + } + + val passkeys = getCredentialsFromDb(relyingParty, mDatabase!!) + + val passkeyEntries: MutableList = mutableListOf() + for (passkey in passkeys) { + val usagePendingIntent = + IntentHelper.generateUsagePendingIntent(applicationContext, passkey.nodeId)!! + passkeyEntries.add( + PublicKeyCredentialEntry( + context = applicationContext, + username = passkey.username, + pendingIntent = usagePendingIntent, + beginGetPublicKeyCredentialOption = option, + displayName = passkey.displayName, + isAutoSelectAllowed = false + ) + ) + } + return passkeyEntries + } + + private fun getCredentialsFromDb(relyingPartyId: String, database: Database): List { + val passkeys = DatabaseHelper.getAllPasskeys(database) + val passkeysMatching = passkeys.filter { p -> p.relyingParty == relyingPartyId } + return passkeysMatching + } + + override fun onClearCredentialStateRequest( + request: ProviderClearCredentialStateRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver + ) { + // nothing to do + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/AppRelyingPartyRelation.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/AppRelyingPartyRelation.kt new file mode 100644 index 000000000..127bc9f03 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/AppRelyingPartyRelation.kt @@ -0,0 +1,18 @@ +package com.kunzisoft.keepass.credentialprovider.util + +class AppRelyingPartyRelation { + + companion object { + fun isRelationValid(relyingParty: String, apkSigningCertificate: ByteArray?): Boolean { + /* + TODO + to implement this, a request to https://$rp/.well-known/assetlinks.json, + parsing the result and matching the hash of the apkSigningCertificate is needed. + This is needed to make sure that a malicious app can not act as an arbitrary relying party. + In short: prevent phishing + */ + return false + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/Base64Helper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/Base64Helper.kt new file mode 100644 index 000000000..e50bda5dd --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/Base64Helper.kt @@ -0,0 +1,20 @@ +package com.kunzisoft.keepass.credentialprovider.util + +import org.apache.commons.codec.binary.Base64 + +class Base64Helper { + + companion object { + + fun b64Decode(encodedString: String?): ByteArray { + return Base64.decodeBase64(encodedString) + } + + fun b64Encode(data: ByteArray): String { + return android.util.Base64.encodeToString( + data, + android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/DatabaseHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/DatabaseHelper.kt new file mode 100644 index 000000000..112683787 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/DatabaseHelper.kt @@ -0,0 +1,98 @@ +package com.kunzisoft.keepass.credentialprovider.util + +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import com.kunzisoft.keepass.credentialprovider.data.Passkey +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.database.DatabaseTaskProvider +import com.kunzisoft.keepass.database.element.Database +import com.kunzisoft.keepass.database.element.Entry +import com.kunzisoft.keepass.database.element.node.NodeIdUUID +import com.kunzisoft.keepass.database.search.SearchHelper +import com.kunzisoft.keepass.database.search.SearchParameters +import com.kunzisoft.keepass.utils.UuidUtil + +@RequiresApi(Build.VERSION_CODES.O) +class DatabaseHelper { + + companion object { + + fun getAllPasskeys(database: Database): List { + val searchHelper = SearchHelper() + val searchParameters = SearchParameters().apply { + searchQuery = PasskeyConverter.PASSKEY_TAG + searchInTitles = false + searchInUsernames = false + searchInPasswords = false + searchInUrls = false + searchInNotes = false + searchInOTP = false + searchInOther = false + searchInUUIDs = false + searchInTags = true + searchInCurrentGroup = false + searchInSearchableGroup = false + searchInRecycleBin = false + searchInTemplates = false + } + val fromGroup = null + val max = Int.MAX_VALUE + val searchResult = searchHelper.createVirtualGroupWithSearchResult( + database, + searchParameters, + fromGroup, + max + ) + ?: return emptyList() + + return PasskeyConverter.convertEntriesListToPasskeys(searchResult.getChildEntries()) + } + + fun searchPassKeyByNodeId(database: Database, nodeId: String): Passkey? { + val uuidToSearch = UuidUtil.fromHexString(nodeId) ?: return null + val nodeIdUUIDToSearch = NodeIdUUID(uuidToSearch) + val entry = database.getEntryById(nodeIdUUIDToSearch) ?: return null + return PasskeyConverter.convertEntryToPasskey(entry) + } + + fun updateEntry( + database: Database, + databaseTaskProvider: DatabaseTaskProvider, + updatedPasskey: Passkey + ) { + val oldEntry = Entry(updatedPasskey.databaseEntry!!) + val entryToUpdate = Entry(updatedPasskey.databaseEntry) + + PasskeyConverter.setPasskeyInEntry(updatedPasskey, entryToUpdate) + + entryToUpdate.setEntryInfo( + database, + entryToUpdate.getEntryInfo( + database, + raw = true, + removeTemplateConfiguration = false + ) + ) + + val save = true + databaseTaskProvider.startDatabaseUpdateEntry(oldEntry, entryToUpdate, save) + Log.d(this::class.java.simpleName, "passkey in entry ${oldEntry.title} updated") + } + + fun saveNewEntry( + database: ContextualDatabase, + databaseTaskProvider: DatabaseTaskProvider, + newPasskey: Passkey + ) { + val newEntry = database.createEntry() ?: throw Exception("can not create new entry") + PasskeyConverter.setPasskeyInEntry(newPasskey, newEntry) + val save = true + + val group = database.rootGroup + ?: throw Exception("can not save new entry in database, because rootGroup is null") + databaseTaskProvider.startDatabaseCreateEntry(newEntry, group, save) + Log.d(this::class.java.simpleName, "new entry saved") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/IntentHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/IntentHelper.kt new file mode 100644 index 000000000..07512a0fd --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/IntentHelper.kt @@ -0,0 +1,195 @@ +package com.kunzisoft.keepass.credentialprovider.util + +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Log +import androidx.annotation.RequiresApi +import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity +import com.kunzisoft.keepass.credentialprovider.activity.CreatePasskeyActivity +import com.kunzisoft.keepass.credentialprovider.activity.UsePasskeyActivity +import com.kunzisoft.keepass.utils.StringUtil.toHexString +import java.security.KeyStore +import java.security.MessageDigest +import java.time.Instant +import javax.crypto.KeyGenerator +import javax.crypto.Mac +import javax.crypto.SecretKey + +@RequiresApi(Build.VERSION_CODES.O) +class IntentHelper { + + companion object { + private const val HMAC_TYPE = "HmacSHA256" + + private const val KEY_NODE_ID = "nodeId" + private const val KEY_TIMESTAMP = "timestamp" + private const val KEY_AUTHENTICATION_CODE = "authenticationCode" + + private const val SEPARATOR = "_" + + private const val NAME_OF_HMAC_KEY = "KeePassDXCredentialProviderHMACKey" + + private const val KEYSTORE_TYPE = "AndroidKeyStore" + + private val PLACEHOLDER_FOR_NEW_NODE_ID = "0".repeat(32) + + private val REGEX_NODE_ID = "[A-F0-9]{32}".toRegex() + private val REGEX_TIMESTAMP = "[0-9]{10}".toRegex() + private val REGEX_AUTHENTICATION_CODE = "[A-F0-9]{64}".toRegex() // 256 bits = 64 hex chars + + private const val MAX_DIFF_IN_SECONDS = 60 + + private var currentRequestCode: Int = 0 + + private fun createPendingIntent( + clazz: Class, + applicationContext: Context, + data: Bundle? = null + ): PendingIntent { + val intent = Intent().setClass(applicationContext, clazz) + + data?.let { intent.putExtras(data) } + + val requestCode = currentRequestCode + // keeps the requestCodes unique, the limit is arbitrary + currentRequestCode = (currentRequestCode + 1) % 1000 + return PendingIntent.getActivity( + applicationContext, + requestCode, + intent, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + } + + private fun createPendingIntentWithAuthenticationCode( + clazz: Class, + applicationContext: Context, + nodeId: String + ): PendingIntent? { + if (nodeId.matches(REGEX_NODE_ID).not()) return null + + val data = Bundle() + val timestamp = Instant.now().epochSecond.toString() + data.putString(KEY_NODE_ID, nodeId) + data.putString(KEY_TIMESTAMP, timestamp) + + val message = nodeId + SEPARATOR + timestamp + val authenticationCode = generateAuthenticationCode(message).toHexString() + + data.putString(KEY_AUTHENTICATION_CODE, authenticationCode) + return createPendingIntent(clazz, applicationContext, data) + } + + fun generateUnlockPendingIntent(applicationContext: Context): PendingIntent { + // TODO after the database is unlocked by the user, return to the flow + return createPendingIntent(FileDatabaseSelectActivity::class.java, applicationContext) + } + + fun generateCreatePendingIntent( + applicationContext: Context, + nodeId: String = PLACEHOLDER_FOR_NEW_NODE_ID + ): PendingIntent? { + return createPendingIntentWithAuthenticationCode( + CreatePasskeyActivity::class.java, + applicationContext, + nodeId + ) + } + + fun generateUsagePendingIntent( + applicationContext: Context, + nodeId: String + ): PendingIntent? { + return createPendingIntentWithAuthenticationCode( + UsePasskeyActivity::class.java, + applicationContext, + nodeId + ) + } + + fun getVerifiedNodeId(intent: Intent): String? { + val nodeId = intent.getStringExtra(KEY_NODE_ID) ?: return null + val timestampString = intent.getStringExtra(KEY_TIMESTAMP) ?: return null + val authenticationCode = intent.getStringExtra(KEY_AUTHENTICATION_CODE) ?: return null + + if (nodeId.matches(REGEX_NODE_ID).not() || + timestampString.matches(REGEX_TIMESTAMP).not() || + authenticationCode.matches(REGEX_AUTHENTICATION_CODE).not() + ) { + return null + } + + val diff = Instant.now().epochSecond - timestampString.toLong() + if (diff < 0 || diff > MAX_DIFF_IN_SECONDS) { + return null + } + + val message = (nodeId + SEPARATOR + timestampString) + if (verifyAuthenticationCode( + message, + authenticationCode.decodeHexToByteArray() + ).not() + ) { + return null + } + Log.d(this::class.java.simpleName, "nodeId $nodeId verified") + return nodeId + } + + private fun verifyAuthenticationCode( + message: String, + authenticationCodeIn: ByteArray + ): Boolean { + val authenticationCode = generateAuthenticationCode(message) + return MessageDigest.isEqual(authenticationCodeIn, authenticationCode) + } + + private fun generateAuthenticationCode(message: String): ByteArray { + val keyStore = KeyStore.getInstance(KEYSTORE_TYPE) + keyStore.load(null) + val hmacKey = try { + keyStore.getKey(NAME_OF_HMAC_KEY, null) as SecretKey + } catch (e: Exception) { + // key not found + generateKey() + } + + val mac = Mac.getInstance(HMAC_TYPE) + mac.init(hmacKey) + val authenticationCode = mac.doFinal(message.toByteArray()) + return authenticationCode + } + + private fun generateKey(): SecretKey? { + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE_TYPE + ) + val keySizeInBits = 128 + keyGenerator.init( + KeyGenParameterSpec.Builder(NAME_OF_HMAC_KEY, KeyProperties.PURPOSE_SIGN) + .setKeySize(keySizeInBits) + .build() + ) + val key = keyGenerator.generateKey() + return key + } + + fun isPlaceholderNodeId(nodeId: String): Boolean { + return nodeId == PLACEHOLDER_FOR_NEW_NODE_ID + } + + private fun String.decodeHexToByteArray(): ByteArray { + if (length % 2 != 0) { + throw IllegalArgumentException("Must have an even length") + } + return chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/JsonHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/JsonHelper.kt new file mode 100644 index 000000000..4df8a7a58 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/JsonHelper.kt @@ -0,0 +1,251 @@ +package com.kunzisoft.keepass.credentialprovider.util + +import android.annotation.SuppressLint +import androidx.credentials.webauthn.Cbor +import com.kunzisoft.encrypt.HashManager +import com.kunzisoft.keepass.credentialprovider.data.PublicKeyCredentialCreationOptions +import com.kunzisoft.keepass.credentialprovider.data.PublicKeyCredentialRequestOptions +import com.kunzisoft.keepass.credentialprovider.util.Base64Helper.Companion.b64Decode +import com.kunzisoft.keepass.credentialprovider.util.Base64Helper.Companion.b64Encode +import org.json.JSONArray +import org.json.JSONObject + +class JsonHelper { + + companion object { + + fun generateClientDataJsonNonPrivileged( + challenge: ByteArray, + origin: String, + packageName: String?, + isGet: Boolean, + isCrossOriginAdded: Boolean + ): String { + val clientJson = JSONObject() + val type = if (isGet) { + "webauthn.get" + } else { + "webauthn.create" + } + clientJson.put("type", type) + clientJson.put("challenge", b64Encode(challenge)) + clientJson.put("origin", origin) + + if (isCrossOriginAdded) { + clientJson.put("crossOrigin", false) + } + + if (packageName != null) { + clientJson.put("androidPackageName", packageName) + } + + val clientDataFinal = clientJson.toString().replace("\\/", "/") + return clientDataFinal + } + + fun generateClientDataJsonPrivileged(): String { + // will be replaced by the clientData from the privileged app like a browser + return "" + } + + fun generateAuthDataForUsage( + rpId: ByteArray, + userPresent: Boolean, + userVerified: Boolean, + backupEligibility: Boolean, + backupState: Boolean, + attestedCredentialData: Boolean = false + ): ByteArray { + val rpHash = HashManager.hashSha256(rpId) + + // see https://www.w3.org/TR/webauthn-3/#table-authData + var flags = 0 + val one = 1 + if (userPresent) { + flags = flags or one.shl(0) + } + // bit at index 1 is reserved + + if (userVerified) { + flags = flags or one.shl(2) + } + if (backupEligibility) { + flags = flags or one.shl(3) + } + if (backupState) { + flags = flags or one.shl(4) + } + + // bit at index 5 is reserved + + if (attestedCredentialData) { + flags = flags or one.shl(6) + } + + // bit at index 7: Extension data included == false + + val signCount = byteArrayOf(0, 0, 0, 0) + + return rpHash + byteArrayOf(flags.toByte()) + signCount + } + + fun generateAuthDataForCreate( + rpId: ByteArray, + userPresent: Boolean, + userVerified: Boolean, + backupEligibility: Boolean, + backupState: Boolean, + credentialId: ByteArray, + credentialPublicKey: ByteArray + ): ByteArray { + val authDataPartOne = generateAuthDataForUsage( + rpId, + userPresent, + userVerified, + backupEligibility, + backupState, + attestedCredentialData = true + ) + + // Authenticator Attestation Globally Unique Identifier + val aaguid = ByteArray(16) { 0 } + + val credIdLen = + byteArrayOf((credentialId.size.shr(8)).toByte(), credentialId.size.toByte()) + + return authDataPartOne + aaguid + credIdLen + credentialId + credentialPublicKey + } + + fun generateDataTosSignNonPrivileged( + clientDataJson: String, + authenticatorData: ByteArray + ): ByteArray { + val hash = HashManager.hashSha256(clientDataJson.toByteArray()) + return authenticatorData + hash + } + + fun generateDataToSignPrivileged( + clientDataHash: ByteArray, + authenticatorData: ByteArray + ): ByteArray { + return authenticatorData + clientDataHash + } + + fun generateAttestationObject(authData: ByteArray): ByteArray { + val ao = mutableMapOf() + ao["fmt"] = "none" + ao["attStmt"] = emptyMap() + ao["authData"] = authData + return generateCborFromMap(ao) + } + + @SuppressLint("RestrictedApi") + fun generateCborFromMap(map: Map): ByteArray { + return Cbor().encode(map) + } + + fun createAuthenticatorAttestationResponseJSON( + credentialId: ByteArray, + clientDataJson: String, + attestationObject: ByteArray, + publicKeyCbor: ByteArray, + authData: ByteArray, + publicKeyTypeId: Long + ): String { + // See AuthenticatorAttestationResponseJSON at + // https://www.w3.org/TR/webauthn-3/#ref-for-dom-publickeycredential-tojson + + val rk = JSONObject() + + // see at https://www.w3.org/TR/webauthn-3/#sctn-authenticator-credential-properties-extension + val discoverableCredential = true + rk.put("rk", discoverableCredential) + val credProps = JSONObject() + credProps.put("credProps", rk) + + + val response = JSONObject() + response.put("attestationObject", b64Encode(attestationObject)) + response.put("clientDataJSON", clientDataJson) + response.put("transports", JSONArray(listOf("internal", "hybrid"))) + response.put("publicKeyAlgorithm", publicKeyTypeId) + response.put("publicKey", b64Encode(publicKeyCbor)) + response.put("authenticatorData", b64Encode(authData)) + + val all = JSONObject() + all.put("id", b64Encode(credentialId)) + all.put("rawId", b64Encode(credentialId)) + all.put("response", response) + all.put("type", "public-key") + all.put("clientExtensionResults", credProps) + all.put("authenticatorAttachment", "platform") + return all.toString() + } + + fun generateGetCredentialResponse( + clientDataJson: ByteArray, + authenticatorData: ByteArray, + signature: ByteArray, + userHandle: String, + id: String + ): String { + + val response = JSONObject() + response.put("clientDataJSON", b64Encode(clientDataJson)) + response.put("authenticatorData", b64Encode(authenticatorData)) + response.put("signature", b64Encode(signature)) + response.put("userHandle", userHandle) + + val ret = JSONObject() + ret.put("id", id) + ret.put("rawId", id) + ret.put("type", "public-key") + ret.put("authenticatorAttachment", "platform") + ret.put("response", response) + ret.put("clientExtensionResults", JSONObject()) + + return ret.toString() + } + + fun parseJsonToRequestOptions(requestJson: String): PublicKeyCredentialRequestOptions { + val jsonObject = JSONObject(requestJson) + + val challengeString = jsonObject.getString("challenge") + val relyingParty = jsonObject.optString("rpId", "") + + return PublicKeyCredentialRequestOptions(relyingParty, challengeString) + } + + fun parseJsonToCreateOptions(requestJson: String): PublicKeyCredentialCreationOptions { + val jsonObject = JSONObject(requestJson) + val rpJson = jsonObject.getJSONObject("rp") + val relyingParty = rpJson.getString("id") + + val challenge = b64Decode(jsonObject.getString("challenge")) + + val rpUser = jsonObject.getJSONObject("user") + val username = rpUser.getString("name") + val userId = b64Decode(rpUser.getString("id")) + + + val pubKeyCredParamsJson = jsonObject.getJSONArray("pubKeyCredParams") + val keyTypeIdList: MutableList = mutableListOf() + for (i in 0 until pubKeyCredParamsJson.length()) { + val e = pubKeyCredParamsJson.getJSONObject(i) + if (e.getString("type") == "public-key") { + keyTypeIdList.add(e.getLong("alg")) + } + } + + return PublicKeyCredentialCreationOptions( + relyingParty, + challenge, + username, + userId, + keyTypeIdList.distinct() + ) + } + + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/OriginHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/OriginHelper.kt new file mode 100644 index 000000000..69383a6a4 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/OriginHelper.kt @@ -0,0 +1,22 @@ +package com.kunzisoft.keepass.credentialprovider.util + +import android.content.res.AssetManager +import androidx.credentials.provider.CallingAppInfo + +class OriginHelper { + + companion object { + + const val DEFAULT_PROTOCOL = "https://" + + fun getWebOrigin(callingAppInfo: CallingAppInfo, assets: AssetManager): String? { + val privilegedAllowlist = assets.open("trustedPackages.json").bufferedReader().use { + it.readText() + } + // for trusted browsers like Chrome and Firefox + val origin = callingAppInfo.getOrigin(privilegedAllowlist)?.removeSuffix("/") + return origin + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/PasskeyConverter.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/PasskeyConverter.kt new file mode 100644 index 000000000..7418a4f94 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/PasskeyConverter.kt @@ -0,0 +1,120 @@ +package com.kunzisoft.keepass.credentialprovider.util + +import android.os.Build +import androidx.annotation.RequiresApi +import com.kunzisoft.keepass.credentialprovider.data.Passkey +import com.kunzisoft.keepass.database.element.DateInstant +import com.kunzisoft.keepass.database.element.Entry +import com.kunzisoft.keepass.database.element.Field +import com.kunzisoft.keepass.database.element.security.ProtectedString +import com.kunzisoft.keepass.utils.UuidUtil + +@RequiresApi(Build.VERSION_CODES.O) +class PasskeyConverter { + + companion object { + + // field names from KeypassXC are used + private const val FIELD_USERNAME = "KPEX_PASSKEY_USERNAME" + private const val FIELD_PRIVATE_KEY = "KPEX_PASSKEY_PRIVATE_KEY_PEM" + private const val FIELD_CREDENTIAL_ID = "KPEX_PASSKEY_CREDENTIAL_ID" + private const val FIELD_USER_HANDLE = "KPEX_PASSKEY_USER_HANDLE" + private const val FIELD_RELYING_PARTY = "KPEX_PASSKEY_RELYING_PARTY" + + const val PASSKEY_TAG = "Passkey" + + fun convertEntryToPasskey(entry: Entry): Passkey? { + if (entry.tags.toList().contains(PASSKEY_TAG).not()) { + return null + } + + val nodeId = UuidUtil.toHexString(entry.nodeId.id) ?: return null + + val displayName = entry.getVisualTitle() + + var username = "" + var privateKeyPem = "" + var credId = "" + var userHandle = "" + var relyingParty = "" + + for (field in entry.getExtraFields()) { + val fieldName = field.name + + if (fieldName == FIELD_USERNAME) { + username = field.protectedValue.stringValue + } else if (field.name == FIELD_PRIVATE_KEY) { + privateKeyPem = field.protectedValue.stringValue + } else if (field.name == FIELD_CREDENTIAL_ID) { + credId = field.protectedValue.stringValue + } else if (field.name == FIELD_USER_HANDLE) { + userHandle = field.protectedValue.stringValue + } else if (field.name == FIELD_RELYING_PARTY) { + relyingParty = field.protectedValue.stringValue + } + } + return Passkey( + nodeId, + username, + displayName, + privateKeyPem, + credId, + userHandle, + relyingParty, + entry + ) + } + + fun convertEntriesListToPasskeys(entries: List): List { + return entries.mapNotNull { e -> convertEntryToPasskey(e) } + } + + + fun setPasskeyInEntry(passkey: Passkey, entry: Entry) { + entry.tags.put(PASSKEY_TAG) + + entry.title = passkey.displayName + entry.lastModificationTime = DateInstant() + + entry.username = passkey.username + + entry.url = OriginHelper.DEFAULT_PROTOCOL + passkey.relyingParty + + val protected = true + val unProtected = false + + entry.putExtraField( + Field( + FIELD_USERNAME, + ProtectedString(unProtected, passkey.username) + ) + ) + entry.putExtraField( + Field( + FIELD_PRIVATE_KEY, + ProtectedString(protected, passkey.privateKeyPem) + ) + ) + entry.putExtraField( + Field( + FIELD_CREDENTIAL_ID, + ProtectedString(protected, passkey.credId) + ) + ) + entry.putExtraField( + Field( + FIELD_USER_HANDLE, + ProtectedString(protected, passkey.userHandle) + ) + ) + entry.putExtraField( + Field( + FIELD_RELYING_PARTY, + ProtectedString(unProtected, passkey.relyingParty) + ) + ) + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/database/DatabaseTaskProvider.kt b/app/src/main/java/com/kunzisoft/keepass/database/DatabaseTaskProvider.kt index cd38f0046..6c81d02a3 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/DatabaseTaskProvider.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/DatabaseTaskProvider.kt @@ -108,14 +108,19 @@ class DatabaseTaskProvider( ) { // To show dialog only if context is an activity - private var activity: FragmentActivity? = try { context as? FragmentActivity? } - catch (_: Exception) { null } + private var activity: FragmentActivity? = try { + context as? FragmentActivity? + } catch (_: Exception) { + null + } var onDatabaseRetrieved: ((database: ContextualDatabase?) -> Unit)? = null - var onActionFinish: ((database: ContextualDatabase, - actionTask: String, - result: ActionRunnable.Result) -> Unit)? = null + var onActionFinish: (( + database: ContextualDatabase, + actionTask: String, + result: ActionRunnable.Result + ) -> Unit)? = null private var intentDatabaseTask: Intent = Intent( context.applicationContext, @@ -141,7 +146,7 @@ class DatabaseTaskProvider( this.databaseChangedDialogFragment = null } - private val actionTaskListener = object: DatabaseTaskNotificationService.ActionTaskListener { + private val actionTaskListener = object : DatabaseTaskNotificationService.ActionTaskListener { override fun onActionStarted( database: ContextualDatabase, progressMessage: ProgressMessage @@ -175,13 +180,14 @@ class DatabaseTaskProvider( } } - private val mActionDatabaseListener = object: DatabaseChangedDialogFragment.ActionDatabaseChangedListener { - override fun validateDatabaseChanged() { - mBinder?.getService()?.saveDatabaseInfo() + private val mActionDatabaseListener = + object : DatabaseChangedDialogFragment.ActionDatabaseChangedListener { + override fun validateDatabaseChanged() { + mBinder?.getService()?.saveDatabaseInfo() + } } - } - private var databaseInfoListener = object: + private var databaseInfoListener = object : DatabaseTaskNotificationService.DatabaseInfoListener { override fun onDatabaseInfoChanged( previousDatabaseInfo: SnapFileDatabaseInfo, @@ -214,7 +220,7 @@ class DatabaseTaskProvider( } } - private var databaseListener = object: DatabaseTaskNotificationService.DatabaseListener { + private var databaseListener = object : DatabaseTaskNotificationService.DatabaseListener { override fun onDatabaseRetrieved(database: ContextualDatabase?) { onDatabaseRetrieved?.invoke(database) } @@ -265,12 +271,13 @@ class DatabaseTaskProvider( } override fun onServiceConnected(name: ComponentName?, serviceBinder: IBinder?) { - mBinder = (serviceBinder as DatabaseTaskNotificationService.ActionTaskBinder?)?.apply { - addServiceListeners(this) - getService().checkDatabase() - getService().checkDatabaseInfo() - getService().checkAction() - } + mBinder = + (serviceBinder as DatabaseTaskNotificationService.ActionTaskBinder?)?.apply { + addServiceListeners(this) + getService().checkDatabase() + getService().checkDatabaseInfo() + getService().checkAction() + } } override fun onServiceDisconnected(name: ComponentName?) { @@ -296,7 +303,11 @@ class DatabaseTaskProvider( private fun bindService() { initServiceConnection() serviceConnection?.let { - context.bindService(intentDatabaseTask, it, BIND_AUTO_CREATE or BIND_IMPORTANT or BIND_ABOVE_CLIENT) + context.bindService( + intentDatabaseTask, + it, + BIND_AUTO_CREATE or BIND_IMPORTANT or BIND_ABOVE_CLIENT + ) } } @@ -324,6 +335,7 @@ class DatabaseTaskProvider( // Bind to the service when is starting bindService() } + DATABASE_STOP_TASK_ACTION -> { // Remove the progress task unBindService() @@ -331,7 +343,8 @@ class DatabaseTaskProvider( } } } - ContextCompat.registerReceiver(context, databaseTaskBroadcastReceiver, + ContextCompat.registerReceiver( + context, databaseTaskBroadcastReceiver, IntentFilter().apply { addAction(DATABASE_START_TASK_ACTION) addAction(DATABASE_STOP_TASK_ACTION) @@ -416,47 +429,51 @@ class DatabaseTaskProvider( ---- */ - fun startDatabaseCreate(databaseUri: Uri, - mainCredential: MainCredential + fun startDatabaseCreate( + databaseUri: Uri, + mainCredential: MainCredential ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.DATABASE_URI_KEY, databaseUri) putParcelable(DatabaseTaskNotificationService.MAIN_CREDENTIAL_KEY, mainCredential) - } - , ACTION_DATABASE_CREATE_TASK) + }, ACTION_DATABASE_CREATE_TASK) } - fun startDatabaseLoad(databaseUri: Uri, - mainCredential: MainCredential, - readOnly: Boolean, - cipherEncryptDatabase: CipherEncryptDatabase?, - fixDuplicateUuid: Boolean) { + fun startDatabaseLoad( + databaseUri: Uri, + mainCredential: MainCredential, + readOnly: Boolean, + cipherEncryptDatabase: CipherEncryptDatabase?, + fixDuplicateUuid: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.DATABASE_URI_KEY, databaseUri) putParcelable(DatabaseTaskNotificationService.MAIN_CREDENTIAL_KEY, mainCredential) putBoolean(DatabaseTaskNotificationService.READ_ONLY_KEY, readOnly) - putParcelable(DatabaseTaskNotificationService.CIPHER_DATABASE_KEY, cipherEncryptDatabase) + putParcelable( + DatabaseTaskNotificationService.CIPHER_DATABASE_KEY, + cipherEncryptDatabase + ) putBoolean(DatabaseTaskNotificationService.FIX_DUPLICATE_UUID_KEY, fixDuplicateUuid) - } - , ACTION_DATABASE_LOAD_TASK) + }, ACTION_DATABASE_LOAD_TASK) } - fun startDatabaseMerge(save: Boolean, - fromDatabaseUri: Uri? = null, - mainCredential: MainCredential? = null) { + fun startDatabaseMerge( + save: Boolean, + fromDatabaseUri: Uri? = null, + mainCredential: MainCredential? = null + ) { start(Bundle().apply { putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) putParcelable(DatabaseTaskNotificationService.DATABASE_URI_KEY, fromDatabaseUri) putParcelable(DatabaseTaskNotificationService.MAIN_CREDENTIAL_KEY, mainCredential) - } - , ACTION_DATABASE_MERGE_TASK) + }, ACTION_DATABASE_MERGE_TASK) } fun startDatabaseReload(fixDuplicateUuid: Boolean) { start(Bundle().apply { putBoolean(DatabaseTaskNotificationService.FIX_DUPLICATE_UUID_KEY, fixDuplicateUuid) - } - , ACTION_DATABASE_RELOAD_TASK) + }, ACTION_DATABASE_RELOAD_TASK) } fun askToStartDatabaseReload(conditionToAsk: Boolean, approved: () -> Unit) { @@ -472,15 +489,15 @@ class DatabaseTaskProvider( } } - fun startDatabaseAssignCredential(databaseUri: Uri, - mainCredential: MainCredential + fun startDatabaseAssignCredential( + databaseUri: Uri, + mainCredential: MainCredential ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.DATABASE_URI_KEY, databaseUri) putParcelable(DatabaseTaskNotificationService.MAIN_CREDENTIAL_KEY, mainCredential) - } - , ACTION_DATABASE_ASSIGN_CREDENTIAL_TASK) + }, ACTION_DATABASE_ASSIGN_CREDENTIAL_TASK) } /* @@ -489,54 +506,60 @@ class DatabaseTaskProvider( ---- */ - fun startDatabaseCreateGroup(newGroup: Group, - parent: Group, - save: Boolean) { + fun startDatabaseCreateGroup( + newGroup: Group, + parent: Group, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.GROUP_KEY, newGroup) putParcelable(DatabaseTaskNotificationService.PARENT_ID_KEY, parent.nodeId) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_CREATE_GROUP_TASK) + }, ACTION_DATABASE_CREATE_GROUP_TASK) } - fun startDatabaseUpdateGroup(oldGroup: Group, - groupToUpdate: Group, - save: Boolean) { + fun startDatabaseUpdateGroup( + oldGroup: Group, + groupToUpdate: Group, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.GROUP_ID_KEY, oldGroup.nodeId) putParcelable(DatabaseTaskNotificationService.GROUP_KEY, groupToUpdate) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_GROUP_TASK) + }, ACTION_DATABASE_UPDATE_GROUP_TASK) } - fun startDatabaseCreateEntry(newEntry: Entry, - parent: Group, - save: Boolean) { + fun startDatabaseCreateEntry( + newEntry: Entry, + parent: Group, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.ENTRY_KEY, newEntry) putParcelable(DatabaseTaskNotificationService.PARENT_ID_KEY, parent.nodeId) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_CREATE_ENTRY_TASK) + }, ACTION_DATABASE_CREATE_ENTRY_TASK) } - fun startDatabaseUpdateEntry(oldEntry: Entry, - entryToUpdate: Entry, - save: Boolean) { + fun startDatabaseUpdateEntry( + oldEntry: Entry, + entryToUpdate: Entry, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.ENTRY_ID_KEY, oldEntry.nodeId) putParcelable(DatabaseTaskNotificationService.ENTRY_KEY, entryToUpdate) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_ENTRY_TASK) + }, ACTION_DATABASE_UPDATE_ENTRY_TASK) } - private fun startDatabaseActionListNodes(actionTask: String, - nodesPaste: List, - newParent: Group?, - save: Boolean) { + private fun startDatabaseActionListNodes( + actionTask: String, + nodesPaste: List, + newParent: Group?, + save: Boolean + ) { val groupsIdToCopy = ArrayList>() val entriesIdToCopy = ArrayList>() nodesPaste.forEach { nodeVersioned -> @@ -544,6 +567,7 @@ class DatabaseTaskProvider( Type.GROUP -> { groupsIdToCopy.add((nodeVersioned as Group).nodeId) } + Type.ENTRY -> { entriesIdToCopy.add((nodeVersioned as Entry).nodeId) } @@ -558,24 +582,29 @@ class DatabaseTaskProvider( if (newParentId != null) putParcelable(DatabaseTaskNotificationService.PARENT_ID_KEY, newParentId) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , actionTask) + }, actionTask) } - fun startDatabaseCopyNodes(nodesToCopy: List, - newParent: Group, - save: Boolean) { + fun startDatabaseCopyNodes( + nodesToCopy: List, + newParent: Group, + save: Boolean + ) { startDatabaseActionListNodes(ACTION_DATABASE_COPY_NODES_TASK, nodesToCopy, newParent, save) } - fun startDatabaseMoveNodes(nodesToMove: List, - newParent: Group, - save: Boolean) { + fun startDatabaseMoveNodes( + nodesToMove: List, + newParent: Group, + save: Boolean + ) { startDatabaseActionListNodes(ACTION_DATABASE_MOVE_NODES_TASK, nodesToMove, newParent, save) } - fun startDatabaseDeleteNodes(nodesToDelete: List, - save: Boolean) { + fun startDatabaseDeleteNodes( + nodesToDelete: List, + save: Boolean + ) { startDatabaseActionListNodes(ACTION_DATABASE_DELETE_NODES_TASK, nodesToDelete, null, save) } @@ -585,26 +614,28 @@ class DatabaseTaskProvider( ----------------- */ - fun startDatabaseRestoreEntryHistory(mainEntryId: NodeId, - entryHistoryPosition: Int, - save: Boolean) { + fun startDatabaseRestoreEntryHistory( + mainEntryId: NodeId, + entryHistoryPosition: Int, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.ENTRY_ID_KEY, mainEntryId) putInt(DatabaseTaskNotificationService.ENTRY_HISTORY_POSITION_KEY, entryHistoryPosition) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_RESTORE_ENTRY_HISTORY) + }, ACTION_DATABASE_RESTORE_ENTRY_HISTORY) } - fun startDatabaseDeleteEntryHistory(mainEntryId: NodeId, - entryHistoryPosition: Int, - save: Boolean) { + fun startDatabaseDeleteEntryHistory( + mainEntryId: NodeId, + entryHistoryPosition: Int, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.ENTRY_ID_KEY, mainEntryId) putInt(DatabaseTaskNotificationService.ENTRY_HISTORY_POSITION_KEY, entryHistoryPosition) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_DELETE_ENTRY_HISTORY) + }, ACTION_DATABASE_DELETE_ENTRY_HISTORY) } /* @@ -613,110 +644,118 @@ class DatabaseTaskProvider( ----------------- */ - fun startDatabaseSaveName(oldName: String, - newName: String, - save: Boolean) { + fun startDatabaseSaveName( + oldName: String, + newName: String, + save: Boolean + ) { start(Bundle().apply { putString(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldName) putString(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newName) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_NAME_TASK) + }, ACTION_DATABASE_UPDATE_NAME_TASK) } - fun startDatabaseSaveDescription(oldDescription: String, - newDescription: String, - save: Boolean) { + fun startDatabaseSaveDescription( + oldDescription: String, + newDescription: String, + save: Boolean + ) { start(Bundle().apply { putString(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldDescription) putString(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newDescription) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_DESCRIPTION_TASK) + }, ACTION_DATABASE_UPDATE_DESCRIPTION_TASK) } - fun startDatabaseSaveDefaultUsername(oldDefaultUsername: String, - newDefaultUsername: String, - save: Boolean) { + fun startDatabaseSaveDefaultUsername( + oldDefaultUsername: String, + newDefaultUsername: String, + save: Boolean + ) { start(Bundle().apply { putString(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldDefaultUsername) putString(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newDefaultUsername) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_DEFAULT_USERNAME_TASK) + }, ACTION_DATABASE_UPDATE_DEFAULT_USERNAME_TASK) } - fun startDatabaseSaveColor(oldColor: String, - newColor: String, - save: Boolean) { + fun startDatabaseSaveColor( + oldColor: String, + newColor: String, + save: Boolean + ) { start(Bundle().apply { putString(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldColor) putString(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newColor) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_COLOR_TASK) + }, ACTION_DATABASE_UPDATE_COLOR_TASK) } - fun startDatabaseSaveCompression(oldCompression: CompressionAlgorithm, - newCompression: CompressionAlgorithm, - save: Boolean) { + fun startDatabaseSaveCompression( + oldCompression: CompressionAlgorithm, + newCompression: CompressionAlgorithm, + save: Boolean + ) { start(Bundle().apply { putSerializable(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldCompression) putSerializable(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newCompression) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_COMPRESSION_TASK) + }, ACTION_DATABASE_UPDATE_COMPRESSION_TASK) } fun startDatabaseRemoveUnlinkedData(save: Boolean) { start(Bundle().apply { putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_REMOVE_UNLINKED_DATA_TASK) + }, ACTION_DATABASE_REMOVE_UNLINKED_DATA_TASK) } - fun startDatabaseSaveRecycleBin(oldRecycleBin: Group?, - newRecycleBin: Group?, - save: Boolean) { + fun startDatabaseSaveRecycleBin( + oldRecycleBin: Group?, + newRecycleBin: Group?, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldRecycleBin) putParcelable(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newRecycleBin) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_RECYCLE_BIN_TASK) + }, ACTION_DATABASE_UPDATE_RECYCLE_BIN_TASK) } - fun startDatabaseSaveTemplatesGroup(oldTemplatesGroup: Group?, - newTemplatesGroup: Group?, - save: Boolean) { + fun startDatabaseSaveTemplatesGroup( + oldTemplatesGroup: Group?, + newTemplatesGroup: Group?, + save: Boolean + ) { start(Bundle().apply { putParcelable(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldTemplatesGroup) putParcelable(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newTemplatesGroup) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_TEMPLATES_GROUP_TASK) + }, ACTION_DATABASE_UPDATE_TEMPLATES_GROUP_TASK) } - fun startDatabaseSaveMaxHistoryItems(oldMaxHistoryItems: Int, - newMaxHistoryItems: Int, - save: Boolean) { + fun startDatabaseSaveMaxHistoryItems( + oldMaxHistoryItems: Int, + newMaxHistoryItems: Int, + save: Boolean + ) { start(Bundle().apply { putInt(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldMaxHistoryItems) putInt(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newMaxHistoryItems) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_MAX_HISTORY_ITEMS_TASK) + }, ACTION_DATABASE_UPDATE_MAX_HISTORY_ITEMS_TASK) } - fun startDatabaseSaveMaxHistorySize(oldMaxHistorySize: Long, - newMaxHistorySize: Long, - save: Boolean) { + fun startDatabaseSaveMaxHistorySize( + oldMaxHistorySize: Long, + newMaxHistorySize: Long, + save: Boolean + ) { start(Bundle().apply { putLong(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldMaxHistorySize) putLong(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newMaxHistorySize) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_MAX_HISTORY_SIZE_TASK) + }, ACTION_DATABASE_UPDATE_MAX_HISTORY_SIZE_TASK) } /* @@ -725,59 +764,64 @@ class DatabaseTaskProvider( ------------------- */ - fun startDatabaseSaveEncryption(oldEncryption: EncryptionAlgorithm, - newEncryption: EncryptionAlgorithm, - save: Boolean) { + fun startDatabaseSaveEncryption( + oldEncryption: EncryptionAlgorithm, + newEncryption: EncryptionAlgorithm, + save: Boolean + ) { start(Bundle().apply { putSerializable(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldEncryption) putSerializable(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newEncryption) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_ENCRYPTION_TASK) + }, ACTION_DATABASE_UPDATE_ENCRYPTION_TASK) } - fun startDatabaseSaveKeyDerivation(oldKeyDerivation: KdfEngine, - newKeyDerivation: KdfEngine, - save: Boolean) { + fun startDatabaseSaveKeyDerivation( + oldKeyDerivation: KdfEngine, + newKeyDerivation: KdfEngine, + save: Boolean + ) { start(Bundle().apply { putSerializable(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldKeyDerivation) putSerializable(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newKeyDerivation) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_KEY_DERIVATION_TASK) + }, ACTION_DATABASE_UPDATE_KEY_DERIVATION_TASK) } - fun startDatabaseSaveIterations(oldIterations: Long, - newIterations: Long, - save: Boolean) { + fun startDatabaseSaveIterations( + oldIterations: Long, + newIterations: Long, + save: Boolean + ) { start(Bundle().apply { putLong(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldIterations) putLong(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newIterations) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_ITERATIONS_TASK) + }, ACTION_DATABASE_UPDATE_ITERATIONS_TASK) } - fun startDatabaseSaveMemoryUsage(oldMemoryUsage: Long, - newMemoryUsage: Long, - save: Boolean) { + fun startDatabaseSaveMemoryUsage( + oldMemoryUsage: Long, + newMemoryUsage: Long, + save: Boolean + ) { start(Bundle().apply { putLong(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldMemoryUsage) putLong(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newMemoryUsage) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_MEMORY_USAGE_TASK) + }, ACTION_DATABASE_UPDATE_MEMORY_USAGE_TASK) } - fun startDatabaseSaveParallelism(oldParallelism: Long, - newParallelism: Long, - save: Boolean) { + fun startDatabaseSaveParallelism( + oldParallelism: Long, + newParallelism: Long, + save: Boolean + ) { start(Bundle().apply { putLong(DatabaseTaskNotificationService.OLD_ELEMENT_KEY, oldParallelism) putLong(DatabaseTaskNotificationService.NEW_ELEMENT_KEY, newParallelism) putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) - } - , ACTION_DATABASE_UPDATE_PARALLELISM_TASK) + }, ACTION_DATABASE_UPDATE_PARALLELISM_TASK) } /** @@ -787,15 +831,13 @@ class DatabaseTaskProvider( start(Bundle().apply { putBoolean(DatabaseTaskNotificationService.SAVE_DATABASE_KEY, save) putParcelable(DatabaseTaskNotificationService.DATABASE_URI_KEY, saveToUri) - } - , ACTION_DATABASE_SAVE) + }, ACTION_DATABASE_SAVE) } fun startChallengeResponded(response: ByteArray?) { start(Bundle().apply { putByteArray(DatabaseTaskNotificationService.DATA_BYTES, response) - } - , ACTION_CHALLENGE_RESPONDED) + }, ACTION_CHALLENGE_RESPONDED) } companion object { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01bff8c47..7270b55cc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -734,7 +734,21 @@ Hide expired entries Expired entries are not shown - Confirm passkey usage - for %1$s - Cancel + Confirm passkey usage + for %1$s + Cancel + + Confirm passkey creation + for %1$s + Cancel + + Save passkey in new entry + Update passkey in "%1$s" + + + Please unlock your database + unknown + + + \ No newline at end of file diff --git a/app/src/main/res/xml/provider.xml b/app/src/main/res/xml/provider.xml index 5dc307a8c..395d04812 100644 --- a/app/src/main/res/xml/provider.xml +++ b/app/src/main/res/xml/provider.xml @@ -1,7 +1,6 @@ - + - diff --git a/crypto/build.gradle b/crypto/build.gradle index ddab8d18a..a1c7c6ec6 100644 --- a/crypto/build.gradle +++ b/crypto/build.gradle @@ -36,11 +36,17 @@ android { kotlinOptions { jvmTarget = "1.8" } + + packaging { + resources.excludes.add("META-INF/versions/9/OSGI-INF/MANIFEST.MF") // bouncycastle need this + } } + dependencies { // Crypto implementation 'org.bouncycastle:bcpkix-jdk18on:1.78.1' - testImplementation "androidx.test:runner:$android_test_version" + androidTestImplementation "androidx.test:runner:$android_test_version" + androidTestImplementation 'org.testng:testng:6.9.6' } diff --git a/crypto/src/androidTest/java/com/kunzisoft/asymmetric/SignatureTest.kt b/crypto/src/androidTest/java/com/kunzisoft/asymmetric/SignatureTest.kt new file mode 100644 index 000000000..557159f98 --- /dev/null +++ b/crypto/src/androidTest/java/com/kunzisoft/asymmetric/SignatureTest.kt @@ -0,0 +1,102 @@ +package com.kunzisoft.asymmetric + +import org.junit.Test + +class SignatureTest { + + // All private keys are for testing only. + // DO NOT USE THEM + + private val es256PemInKeypassXC = + """ + -----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgaIrmuL+0IpvMpZ4O + 8+CpXEzVNoyNkhquyRqD8CtVWDmhRANCAARyucecj8E9YvcAZHEYgElcLjwLMWmM + vQ2BDZPVL4pLG1oBZer1mPEEQV7LzwGYvTzV/eb9GlXPwj/4la/bpVp1 + -----END PRIVATE KEY----- + """.trimIndent().trim() + + private val es256PemInKeypassDX = """ + -----BEGIN PRIVATE KEY----- + MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgaIrmuL+0IpvMpZ4O + 8+CpXEzVNoyNkhquyRqD8CtVWDmgCgYIKoZIzj0DAQehRANCAARyucecj8E9YvcA + ZHEYgElcLjwLMWmMvQ2BDZPVL4pLG1oBZer1mPEEQV7LzwGYvTzV/eb9GlXPwj/4 + la/bpVp1 + -----END PRIVATE KEY----- + """.trimIndent().trim() + + @Test + fun testEC256KeyConversionKeypassXCIn() { + val privateKey = Signature.createPrivateKey(es256PemInKeypassXC) + val pemOut = Signature.convertPrivateKeyToPem(privateKey) + + assert(pemOut == es256PemInKeypassDX) + } + + @Test + fun testEC256KeyConversionKeypassDXIn() { + val privateKey = Signature.createPrivateKey(es256PemInKeypassDX) + val pemOut = Signature.convertPrivateKeyToPem(privateKey) + + assert(pemOut == es256PemInKeypassDX) + } + + @Test + fun testEC256KeyGenAndConversion() { + val (keyPair, keyTypeId) = Signature.generateKeyPair(listOf(Signature.ES256_ALGORITHM))!! + val privateKeyPem = Signature.convertPrivateKeyToPem(keyPair.private) + + assert(keyTypeId == Signature.ES256_ALGORITHM) + assert(privateKeyPem.contains("-----BEGIN PRIVATE KEY-----", true)) + assert(privateKeyPem.contains("-----BEGIN EC PRIVATE KEY-----", true).not()) + + } + + private val rsa256PemIn = """ + -----BEGIN PRIVATE KEY----- + MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCaunVJEhLHl7/f + NZufOmj4MY/1J/YHgMAZYFBORQVm58psUjCU7jIww+BK5aRGShdumRzbxr1Yqyh6 + yvWbv2u9l6cOdtQKFtXDsWtP9tMBqqhODhG30gE3rEt5l2k1CSzSO9sGghUxlb2i + Q9fiSQ4HmiEc+cXbsSbeYsWwGYNYNhPdJ7vwsZsXzmD0RPpcxy0uJatASjWx3lXF + +eprpcZUr0NLlGIob6VfCt0q2ZfoeEHcEcp4qQ9nI+hOLPFzp/x1TFLX8wKvmwRh + ifwyAauVIaDZaQvAeoBuV2hSBl596ujiJt2Kd3pbQ4SjD1MXudbVvGkofgSZNR0f + ai6f6POFAgMBAAECggEAFtSIVb3K85RahU7dpYLy1hxKB3xb+wNuVNA3STU59NMi + tRTzgiYbVcKxJ5v2v0BTcMg6z9rlOV4X3PZxgwedmB32UlYKN2rjI7rcALKEs+xA + ZTQCPUNJVrOfd1N1/JNb/7FBQhaTlftoPbcQ9Zyd61U8qY/ZN+9NsuaUEMXS8YLe + cqlwJjRcWh3PuTQ+qeVw5l6lgK4XEyDbh/Aj9DGgwVsAkwGdXpuQRBQr8UClO/he + 2iOwkn4LJ5nnXwByMpEct03+eUj0kxlijunYbBnKJfRv6tz+ZcpZoc1EqeWbrTB0 + eKf+R6N8MHgJSemVVGZvgsUYbfMqkJA/LNOyIpQ/wQKBgQDJhowHVDtze+FQrunR + NchOXgZNWTFf3ZITxxnWnTgumtKdg3MkxeKEBCzAqefb6n6zi2rQzP/PAZAKT/we + YP24hwUVeFePH9/Llf5QuCOWGtkZbNRFSCHWcbfRQAL4vfPJk79bhwCoC5wQ5uk1 + atCA+dln6b3wDXq2bvBs6Rj7bQKBgQDEjZIMMgYoEq6yKCFK+11BFo3sj3rmbCcE + tu29mXBfromgzfL0NLoqUAB5OsYKO1nl7eQp2QVIgdLLs8BwTKkel4gosK9B5T4C + umFG0yGIOJz7twA5joLuZAFsoPazj6yHUXaFJaye2P5KwVCL9ws5V2WKgnsF/hKe + QWwSIjtxeQKBgAbyR1NdWOtDIuIYFWErvGrPHOJ/p48JYSajX0Whh7U7ivT4+fgT + hhpM1ooRkTdoXtOrg5QM7OhiwmdImIUnjLdWmBtEWahKTfmDgw+fOULMTB1vPeXh + daEhrFdfIHsYeRXCrP7nqWMhe1Ct1O4Nb4BynEbTrMNgg5FUQ59NbZoFAoGAXNNb + YSUS4UQJexwWtRnHbeDgABO3ADGdr81QtBVOC/IbD3WUQx7PuQH1Z0uJkfV7vGpA + Mj9LDnY5fniS7rZVvJvl8wmWi3FfetxY6qD1mibahMplcclLLpjOT2YpfJ3i5jlj + 1vf28UIbvmRTzPZMN7V9wA9lWGwokNLm3h2Ko0kCgYBq0NEd+VMkuIXuGz6j2IXC + qjKf187RZAn2B7otXoumCze+uxm4N0PyOYb1fNGHeE8/RjNQO7VmzZg/dMrk9ZJh + ueHJgOLTbDdlQCUacSipHGmWMN9E+EjgBRiqmPZzV6dq/kGc2FUSGB22wY8gckEX + AmqgkPgYHZ/VzFPTrp97IQ== + -----END PRIVATE KEY----- + """.trimIndent().trim() + + @Test + fun testRS256KeyConversion() { + val privateKey = Signature.createPrivateKey(rsa256PemIn) + val pemOut = Signature.convertPrivateKeyToPem(privateKey) + + assert(pemOut == rsa256PemIn) + } + + @Test + fun testRS256KeyGenAndConversion() { + val (keyPair, keyTypeId) = Signature.generateKeyPair(listOf(Signature.RS256_ALGORITHM))!! + val privateKeyPem = Signature.convertPrivateKeyToPem(keyPair.private) + + assert(keyTypeId == Signature.RS256_ALGORITHM) + assert(privateKeyPem.contains("-----BEGIN PRIVATE KEY-----", true)) + } +} \ No newline at end of file diff --git a/crypto/src/main/java/com/kunzisoft/asymmetric/Signature.kt b/crypto/src/main/java/com/kunzisoft/asymmetric/Signature.kt new file mode 100644 index 000000000..e311cabb2 --- /dev/null +++ b/crypto/src/main/java/com/kunzisoft/asymmetric/Signature.kt @@ -0,0 +1,176 @@ +package com.kunzisoft.asymmetric + +import android.util.Log +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo +import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey +import org.bouncycastle.jcajce.provider.asymmetric.rsa.BCRSAPublicKey +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.openssl.PEMParser +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter +import org.bouncycastle.openssl.jcajce.JcaPKCS8Generator +import org.bouncycastle.util.BigIntegers +import org.bouncycastle.util.io.pem.PemWriter +import java.io.StringReader +import java.io.StringWriter +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.PrivateKey +import java.security.PublicKey +import java.security.Security +import java.security.Signature +import java.security.spec.ECGenParameterSpec + +object Signature { + + // see at https://www.iana.org/assignments/cose/cose.xhtml + + const val ES256_ALGORITHM: Long = -7 + + const val RS256_ALGORITHM: Long = -257 + private const val RS256_KEY_SIZE_IN_BITS = 2048 + + init { + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) + Security.addProvider(BouncyCastleProvider()) + } + + fun sign(privateKeyPem: String, message: ByteArray): ByteArray? { + val privateKey = createPrivateKey(privateKeyPem) + val algorithmKey = privateKey.algorithm + val algorithmSignature = when (algorithmKey) { + "EC" -> "SHA256withECDSA" + "ECDSA" -> "SHA256withECDSA" + "RSA" -> "SHA256withRSA" + else -> null + } + if (algorithmSignature == null) { + Log.e(this::class.java.simpleName, "sign: privateKeyPem has an unknown algorithm") + return null + } + val sig = Signature.getInstance(algorithmSignature, BouncyCastleProvider.PROVIDER_NAME) + sig.initSign(privateKey) + sig.update(message) + return sig.sign() + } + + fun createPrivateKey(privateKeyPem: String): PrivateKey { + val targetReader = StringReader(privateKeyPem) + val pemParser = PEMParser(targetReader) + val privateKeyInfo = pemParser.readObject() as PrivateKeyInfo + val privateKey = JcaPEMKeyConverter().getPrivateKey(privateKeyInfo) + pemParser.close() + targetReader.close() + return privateKey + } + + fun convertPrivateKeyToPem(privateKey: PrivateKey): String { + val noOutputEncryption = null + val pemObjectGenerator = JcaPKCS8Generator(privateKey, noOutputEncryption) + + val writer = StringWriter() + val pemWriter = PemWriter(writer) + pemWriter.writeObject(pemObjectGenerator) + pemWriter.close() + + val privateKeyInPem = writer.toString().trim() + writer.close() + return privateKeyInPem + } + + fun generateKeyPair(keyTypeIdList: List): Pair? { + + for (typeId in keyTypeIdList) { + if (typeId == ES256_ALGORITHM) { + val es256CurveNameBC = "secp256r1" + val spec = ECGenParameterSpec(es256CurveNameBC) + val keyPairGen = + KeyPairGenerator.getInstance("EC", BouncyCastleProvider.PROVIDER_NAME) + keyPairGen.initialize(spec) + val keyPair = keyPairGen.genKeyPair() + return Pair(keyPair, ES256_ALGORITHM) + } else if (typeId == RS256_ALGORITHM) { + + val keyPairGen = + KeyPairGenerator.getInstance("RSA", BouncyCastleProvider.PROVIDER_NAME) + keyPairGen.initialize(RS256_KEY_SIZE_IN_BITS) + val keyPair = keyPairGen.genKeyPair() + return Pair(keyPair, RS256_ALGORITHM) + } + } + + Log.e(this::class.java.simpleName, "generateKeyPair: no known key type id found") + return null + } + + fun convertPublicKey(publicKeyIn: PublicKey, keyTypeId: Long): ByteArray? { + if (keyTypeId == ES256_ALGORITHM) { + if (publicKeyIn is BCECPublicKey) { + publicKeyIn.setPointFormat("UNCOMPRESSED") + return publicKeyIn.encoded + } + } else if (keyTypeId == RS256_ALGORITHM) { + return publicKeyIn.encoded + } + Log.e(this::class.java.simpleName, "convertPublicKey: unknown key type id found") + return null + } + + fun convertPublicKeyToMap(publicKeyIn: PublicKey, keyTypeId: Long): Map? { + if (keyTypeId == ES256_ALGORITHM) { + if (publicKeyIn !is BCECPublicKey) { + Log.e( + this::class.java.simpleName, + "publicKey object has wrong type for keyTypeId $ES256_ALGORITHM: ${publicKeyIn.javaClass.canonicalName}" + ) + return null + } + // constants see at https://w3c.github.io/webauthn/#example-bdbd14cc + val publicKeyMap = mutableMapOf() + + val es256KeyTypeId = 2 + val es256EllipticCurveP256Id = 1 + + publicKeyMap[1] = es256KeyTypeId + publicKeyMap[3] = ES256_ALGORITHM + publicKeyMap[-1] = es256EllipticCurveP256Id + + val ecPoint = publicKeyIn.q + publicKeyMap[-2] = ecPoint.xCoord.encoded + publicKeyMap[-3] = ecPoint.yCoord.encoded + + return publicKeyMap + + } else if (keyTypeId == RS256_ALGORITHM) { + if (publicKeyIn !is BCRSAPublicKey) { + Log.e( + this::class.java.simpleName, + "publicKey object has wrong type for keyTypeId $RS256_ALGORITHM: ${publicKeyIn.javaClass.canonicalName}" + ) + return null + } + + // constants see at https://w3c.github.io/webauthn/#example-8dfabc00 + + val rs256KeySizeInBytes = RS256_KEY_SIZE_IN_BITS / 8 + val rs256KeyTypeId = 3 + val rs256ExponentSizeInBytes = 3 + + val publicKeyMap = mutableMapOf() + publicKeyMap[1] = rs256KeyTypeId + publicKeyMap[3] = RS256_ALGORITHM + publicKeyMap[-1] = + BigIntegers.asUnsignedByteArray(rs256KeySizeInBytes, publicKeyIn.modulus) + publicKeyMap[-2] = + BigIntegers.asUnsignedByteArray( + rs256ExponentSizeInBytes, + publicKeyIn.publicExponent + ) + return publicKeyMap + } + + Log.e(this::class.java.simpleName, "convertPublicKeyToMap: no known key type id found") + return null + } + + +} \ No newline at end of file diff --git a/crypto/src/main/java/com/kunzisoft/random/KeePassDXRandom.kt b/crypto/src/main/java/com/kunzisoft/random/KeePassDXRandom.kt new file mode 100644 index 000000000..7a54efe0d --- /dev/null +++ b/crypto/src/main/java/com/kunzisoft/random/KeePassDXRandom.kt @@ -0,0 +1,21 @@ +package com.kunzisoft.random + +import java.security.SecureRandom + +class KeePassDXRandom { + + companion object { + + private val internalSecureRandom: SecureRandom = SecureRandom() + + fun generateCredentialId(): ByteArray { + // see https://w3c.github.io/webauthn/#credential-id + val size = 16 + val credentialId = ByteArray(size) + internalSecureRandom.nextBytes(credentialId) + return credentialId + } + + } + +} \ No newline at end of file diff --git a/crypto/src/main/java/com/kunzisoft/signature/Signature.kt b/crypto/src/main/java/com/kunzisoft/signature/Signature.kt deleted file mode 100644 index ee94f44ca..000000000 --- a/crypto/src/main/java/com/kunzisoft/signature/Signature.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.kunzisoft.signature - -import android.util.Log -import org.bouncycastle.asn1.pkcs.PrivateKeyInfo -import org.bouncycastle.jce.provider.BouncyCastleProvider -import java.io.StringReader -import java.security.PrivateKey -import java.security.Security -import java.security.Signature - -import org.bouncycastle.openssl.PEMParser -import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter - -object Signature { - - init { - Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) - Security.addProvider(BouncyCastleProvider()) - } - fun sign(privateKeyPem: String, message: ByteArray): ByteArray { - val privateKey = createPrivateKey(privateKeyPem) - val algorithmKey = privateKey.algorithm - val algorithmSignature = when (algorithmKey) { - "EC" -> "SHA256withECDSA" - "ECDSA" -> "SHA256withECDSA" - "RSA" -> "SHA256withRSA" - else -> "no signature algorithms known" - } - val sig = Signature.getInstance(algorithmSignature, BouncyCastleProvider.PROVIDER_NAME) - sig.initSign(privateKey) - sig.update(message) - return sig.sign() - } - - private fun createPrivateKey(privateKeyPem: String): PrivateKey { - val targetReader = StringReader(privateKeyPem); - val a = PEMParser(targetReader) - val privateKeyInfo = a.readObject() as PrivateKeyInfo - val privateKey = JcaPEMKeyConverter().getPrivateKey(privateKeyInfo) - return privateKey - } - -} \ No newline at end of file From a2eac2ff760dd8c8c8c6d9623dd0473a03388324 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Tue, 8 Jul 2025 15:44:57 +0200 Subject: [PATCH 003/136] fix: KeePassDX name --- app/src/main/AndroidManifest.xml | 2 +- .../java/com/kunzisoft/asymmetric/SignatureTest.kt | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e87c8717b..72cc4bc09 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -268,7 +268,7 @@ android:name="com.kunzisoft.keepass.credentialprovider.service.KeePassDXCredentialProviderService" android:enabled="true" android:exported="true" - android:label="KeyPassDX Credential Provider" + android:label="KeePassDX Credential Provider" android:icon="@mipmap/ic_launcher" android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE" tools:targetApi="upside_down_cake"> diff --git a/crypto/src/androidTest/java/com/kunzisoft/asymmetric/SignatureTest.kt b/crypto/src/androidTest/java/com/kunzisoft/asymmetric/SignatureTest.kt index 557159f98..34867e4d9 100644 --- a/crypto/src/androidTest/java/com/kunzisoft/asymmetric/SignatureTest.kt +++ b/crypto/src/androidTest/java/com/kunzisoft/asymmetric/SignatureTest.kt @@ -7,7 +7,7 @@ class SignatureTest { // All private keys are for testing only. // DO NOT USE THEM - private val es256PemInKeypassXC = + private val es256PemInKeePassXC = """ -----BEGIN PRIVATE KEY----- MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgaIrmuL+0IpvMpZ4O @@ -16,7 +16,7 @@ class SignatureTest { -----END PRIVATE KEY----- """.trimIndent().trim() - private val es256PemInKeypassDX = """ + private val es256PemInKeePassDX = """ -----BEGIN PRIVATE KEY----- MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgaIrmuL+0IpvMpZ4O 8+CpXEzVNoyNkhquyRqD8CtVWDmgCgYIKoZIzj0DAQehRANCAARyucecj8E9YvcA @@ -27,18 +27,18 @@ class SignatureTest { @Test fun testEC256KeyConversionKeypassXCIn() { - val privateKey = Signature.createPrivateKey(es256PemInKeypassXC) + val privateKey = Signature.createPrivateKey(es256PemInKeePassXC) val pemOut = Signature.convertPrivateKeyToPem(privateKey) - assert(pemOut == es256PemInKeypassDX) + assert(pemOut == es256PemInKeePassDX) } @Test - fun testEC256KeyConversionKeypassDXIn() { - val privateKey = Signature.createPrivateKey(es256PemInKeypassDX) + fun testEC256KeyConversionKeePassDXIn() { + val privateKey = Signature.createPrivateKey(es256PemInKeePassDX) val pemOut = Signature.convertPrivateKeyToPem(privateKey) - assert(pemOut == es256PemInKeypassDX) + assert(pemOut == es256PemInKeePassDX) } @Test From 41025f64c0d8ff5727d376eebf2bbd39d8333f31 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Tue, 8 Jul 2025 16:20:17 +0200 Subject: [PATCH 004/136] fix: Add fennec_fdroid according to https://github.com/Kunzisoft/KeePassDX/issues/1421#issuecomment-2872838246 --- app/src/main/assets/trustedPackages.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/src/main/assets/trustedPackages.json b/app/src/main/assets/trustedPackages.json index 263805e82..8a24f37bb 100644 --- a/app/src/main/assets/trustedPackages.json +++ b/app/src/main/assets/trustedPackages.json @@ -148,6 +148,18 @@ ] } }, + { + "type": "android", + "info": { + "package_name": "org.mozilla.fennec_fdroid", + "signatures": [ + { + "build": "release", + "cert_fingerprint_sha256": "06:66:53:58:EF:D8:BA:05:BE:23:6A:47:A1:2C:B0:95:8D:7D:75:DD:93:9D:77:C2:B3:1F:53:98:53:7E:BD:C5" + } + ] + } + }, { "type": "android", "info": { From 488fd60d5d2aba5436df3ec4bd6713a604feee08 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Tue, 8 Jul 2025 17:23:20 +0200 Subject: [PATCH 005/136] fix: Better handleCreatePasskeyQuery implementation --- .../KeePassDXCredentialProviderService.kt | 142 +++++++++--------- app/src/main/res/values/strings.xml | 4 +- 2 files changed, 76 insertions(+), 70 deletions(-) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/service/KeePassDXCredentialProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/service/KeePassDXCredentialProviderService.kt index 0ffd0e661..e8613a517 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/service/KeePassDXCredentialProviderService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/service/KeePassDXCredentialProviderService.kt @@ -57,10 +57,9 @@ class KeePassDXCredentialProviderService : CredentialProviderService() { callback: OutcomeReceiver, ) { Log.d(javaClass.simpleName, "onBeginCreateCredentialRequest called") - val response: BeginCreateCredentialResponse? = processCreateCredentialRequest(request) - if (response != null) { + processCreateCredentialRequest(request)?.let { response -> callback.onResult(response) - } else { + } ?: let { callback.onError(CreateCredentialUnknownException()) } } @@ -78,43 +77,49 @@ class KeePassDXCredentialProviderService : CredentialProviderService() { } private fun handleCreatePasskeyQuery(request: BeginCreatePublicKeyCredentialRequest): BeginCreateCredentialResponse { - if (mDatabase == null) { - // database is locked, a dummy entry is shown. - val messageToUnlockDatabase = getString(R.string.passkey_usage_unlock_database_message) - val dummyEntryList = listOf( - CreateEntry( - getString(R.string.passkey_unknown_username), - IntentHelper.generateUnlockPendingIntent(applicationContext), - messageToUnlockDatabase - ) - ) - return BeginCreateCredentialResponse(dummyEntryList) - } + val accountName = mDatabase?.name ?: getString(R.string.passkey_locked_database_account_name) val createEntries: MutableList = mutableListOf() - val accountName = mDatabase!!.name - val descriptionNewEntry = getString(R.string.passkey_creation_description) - val createPendingIntentNewEntry = - IntentHelper.generateCreatePendingIntent(applicationContext)!! - createEntries.add( - CreateEntry( - accountName, - createPendingIntentNewEntry, - descriptionNewEntry - ) - ) - val relyingParty = JsonHelper.parseJsonToCreateOptions(request.requestJson).relyingParty - val passkeyList = getCredentialsFromDb(relyingParty, mDatabase!!) - for (passkey in passkeyList) { - val createPendingIntent = - IntentHelper.generateCreatePendingIntent(applicationContext, passkey.nodeId)!! - val description = getString(R.string.passkey_update_description, passkey.displayName) + mDatabase?.let { database -> + // To create a new entry + IntentHelper.generateCreatePendingIntent(applicationContext) + ?.let { pendingIntentNewEntry -> + createEntries.add( + CreateEntry( + accountName = accountName, + pendingIntent = pendingIntentNewEntry, + description = getString(R.string.passkey_creation_description) + ) + ) + } + + // To select an existing entry + for (passkey in getCredentialsFromDb( + relyingPartyId = JsonHelper.parseJsonToCreateOptions(request.requestJson).relyingParty, + database = database + )) { + IntentHelper.generateCreatePendingIntent(applicationContext, passkey.nodeId) + ?.let { createPendingIntent -> + createEntries.add( + CreateEntry( + accountName = accountName, + pendingIntent = createPendingIntent, + description = getString( + R.string.passkey_update_description, + passkey.displayName + ) + ) + ) + } + } + } ?: run { + // Database is locked, an entry is shown to unlock it createEntries.add( CreateEntry( - accountName, - createPendingIntent, - description + accountName = accountName, + pendingIntent = IntentHelper.generateUnlockPendingIntent(applicationContext), + description = getString(R.string.passkey_locked_database_description) ) ) } @@ -128,10 +133,9 @@ class KeePassDXCredentialProviderService : CredentialProviderService() { callback: OutcomeReceiver, ) { Log.d(javaClass.simpleName, "onBeginGetCredentialRequest called") - val response = processGetCredentialsRequest(request) - if (response != null) { + processGetCredentialsRequest(request)?.let { response -> callback.onResult(response) - } else { + } ?: run { callback.onError(GetCredentialUnknownException()) } } @@ -155,41 +159,43 @@ class KeePassDXCredentialProviderService : CredentialProviderService() { private fun populatePasskeyData(option: BeginGetPublicKeyCredentialOption): List { - val relyingParty = JsonHelper.parseJsonToRequestOptions(option.requestJson).relyingParty - if (relyingParty.isBlank()) { - throw CreateCredentialUnknownException("relying party id is null or blank") - } - - if (mDatabase == null) { - val unknownUsername = getString(R.string.passkey_unknown_username) - val messageToUnlockDatabase = getString(R.string.passkey_usage_unlock_database_message) - val unlockPendingIntent = IntentHelper.generateUnlockPendingIntent(applicationContext) - val entry = PublicKeyCredentialEntry( - context = applicationContext, - username = unknownUsername, - pendingIntent = unlockPendingIntent, - beginGetPublicKeyCredentialOption = option, - displayName = messageToUnlockDatabase, - lastUsedTime = Instant.now(), - isAutoSelectAllowed = true - ) - return listOf(entry) - } - - val passkeys = getCredentialsFromDb(relyingParty, mDatabase!!) - val passkeyEntries: MutableList = mutableListOf() - for (passkey in passkeys) { - val usagePendingIntent = - IntentHelper.generateUsagePendingIntent(applicationContext, passkey.nodeId)!! + + mDatabase?.let { database -> + // Retrieve passkeys entries from database + val relyingParty = JsonHelper.parseJsonToRequestOptions(option.requestJson).relyingParty + if (relyingParty.isBlank()) { + throw CreateCredentialUnknownException("relying party id is null or blank") + } + for (passkey in getCredentialsFromDb( + relyingPartyId = relyingParty, + database = database + )) { + IntentHelper.generateUsagePendingIntent(applicationContext, passkey.nodeId) + ?.let { usagePendingIntent -> + passkeyEntries.add( + PublicKeyCredentialEntry( + context = applicationContext, + username = passkey.username, + pendingIntent = usagePendingIntent, + beginGetPublicKeyCredentialOption = option, + displayName = passkey.displayName, + isAutoSelectAllowed = false + ) + ) + } + } + } ?: run { + // Database is locked, a public key credential entry is shown to unlock it passkeyEntries.add( PublicKeyCredentialEntry( context = applicationContext, - username = passkey.username, - pendingIntent = usagePendingIntent, + username = getString(R.string.passkey_locked_database_account_name), + pendingIntent = IntentHelper.generateUnlockPendingIntent(applicationContext), beginGetPublicKeyCredentialOption = option, - displayName = passkey.displayName, - isAutoSelectAllowed = false + displayName = getString(R.string.passkey_locked_database_description), + lastUsedTime = Instant.now(), + isAutoSelectAllowed = true ) ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f82fc22f..c98e6e36c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -747,6 +747,6 @@ Cancel Save passkey in new entry Update passkey in "%1$s" - Please unlock your database - unknown + Select to unlock + KeePassDX Database Locked \ No newline at end of file From d1f463d497f8b3fc0842792c470524d005daa70a Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Wed, 16 Jul 2025 14:03:48 +0200 Subject: [PATCH 006/136] fix: Refactoring Credential Provider --- app/src/main/AndroidManifest.xml | 29 +- .../keepass/activities/EntryActivity.kt | 8 +- .../keepass/activities/EntryEditActivity.kt | 130 +++- .../activities/FileDatabaseSelectActivity.kt | 52 +- .../keepass/activities/GroupActivity.kt | 591 ++++++++++++------ .../activities/MainCredentialActivity.kt | 223 ++++--- .../activities/fragments/GroupFragment.kt | 4 +- .../keepass/activities/helpers/TypeMode.kt | 5 - .../activities/legacy/DatabaseLockActivity.kt | 4 +- .../activities/legacy/DatabaseModeActivity.kt | 23 +- .../EntrySelectionHelper.kt | 177 +++++- .../SpecialMode.kt | 2 +- .../keepass/credentialprovider/TypeMode.kt | 5 + .../activity}/AutofillLauncherActivity.kt | 152 +++-- .../activity/CreatePasskeyActivity.kt | 281 --------- .../EntrySelectionLauncherActivity.kt | 176 +++--- .../activity/PasskeyLauncherActivity.kt | 348 +++++++++++ .../activity/UsePasskeyActivity.kt | 236 ------- .../autofill/AutofillComponent.kt | 2 +- .../autofill/AutofillHelper.kt | 73 +-- .../CompatInlineSuggestionsRequest.kt | 2 +- .../autofill/KeeAutofillService.kt | 63 +- .../autofill/StructureParser.kt | 2 +- .../credentialprovider/data/Passkey.kt | 14 - .../PublicKeyCredentialCreationOptions.kt | 9 - .../magikeyboard/Keyboard.java | 2 +- .../magikeyboard/KeyboardView.java | 16 +- .../magikeyboard/MagikeyboardService.kt | 46 +- .../passkey/PasskeyProviderService.kt | 320 ++++++++++ .../PublicKeyCredentialCreationOptions.kt | 9 + .../PublicKeyCredentialCreationParameters.kt | 11 + .../data/PublicKeyCredentialRequestOptions.kt | 5 +- .../PublicKeyCredentialUsageParameters.kt | 9 + .../util/AppRelyingPartyRelation.kt | 2 +- .../{ => passkey}/util/Base64Helper.kt | 2 +- .../{ => passkey}/util/JsonHelper.kt | 12 +- .../{ => passkey}/util/OriginHelper.kt | 7 +- .../passkey/util/PasskeyHelper.kt | 428 +++++++++++++ .../KeePassDXCredentialProviderService.kt | 219 ------- .../credentialprovider/util/DatabaseHelper.kt | 98 --- .../credentialprovider/util/IntentHelper.kt | 195 ------ .../util/PasskeyConverter.kt | 120 ---- .../keepass/database/helper/SearchHelper.kt | 19 +- .../keepass/receivers/DexModeReceiver.kt | 4 - .../KeyboardEntryNotificationService.kt | 2 +- .../keepass/utils/BroadcastAction.kt | 2 +- .../keepass/utils/MagikeyboardUtil.kt | 2 +- .../main/res/layout/keyboard_container.xml | 2 +- app/src/main/res/values/strings.xml | 12 +- database/build.gradle | 1 + .../keepass/database/element/Database.kt | 28 +- .../keepass/database/element/Tags.kt | 4 + .../keepass/database/search/SearchHelper.kt | 7 +- .../database/search/SearchParameters.kt | 1 + .../com/kunzisoft/keepass/model/EntryInfo.kt | 22 +- .../keepass/model/EntryInfoPasskey.kt | 91 +++ .../com/kunzisoft/keepass/model/Passkey.kt | 14 + .../kunzisoft/keepass/model/RegisterInfo.kt | 13 +- .../com/kunzisoft/keepass/model/SearchInfo.kt | 57 +- 59 files changed, 2496 insertions(+), 1897 deletions(-) delete mode 100644 app/src/main/java/com/kunzisoft/keepass/activities/helpers/TypeMode.kt rename app/src/main/java/com/kunzisoft/keepass/{activities/helpers => credentialprovider}/EntrySelectionHelper.kt (55%) rename app/src/main/java/com/kunzisoft/keepass/{activities/helpers => credentialprovider}/SpecialMode.kt (65%) create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/TypeMode.kt rename app/src/main/java/com/kunzisoft/keepass/{activities => credentialprovider/activity}/AutofillLauncherActivity.kt (70%) delete mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/CreatePasskeyActivity.kt rename app/src/main/java/com/kunzisoft/keepass/{activities => credentialprovider/activity}/EntrySelectionLauncherActivity.kt (59%) create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt delete mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/UsePasskeyActivity.kt rename app/src/main/java/com/kunzisoft/keepass/{ => credentialprovider}/autofill/AutofillComponent.kt (78%) rename app/src/main/java/com/kunzisoft/keepass/{ => credentialprovider}/autofill/AutofillHelper.kt (88%) rename app/src/main/java/com/kunzisoft/keepass/{ => credentialprovider}/autofill/CompatInlineSuggestionsRequest.kt (97%) rename app/src/main/java/com/kunzisoft/keepass/{ => credentialprovider}/autofill/KeeAutofillService.kt (92%) rename app/src/main/java/com/kunzisoft/keepass/{ => credentialprovider}/autofill/StructureParser.kt (99%) delete mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/Passkey.kt delete mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialCreationOptions.kt rename app/src/main/java/com/kunzisoft/keepass/{ => credentialprovider}/magikeyboard/Keyboard.java (99%) rename app/src/main/java/com/kunzisoft/keepass/{ => credentialprovider}/magikeyboard/KeyboardView.java (98%) rename app/src/main/java/com/kunzisoft/keepass/{ => credentialprovider}/magikeyboard/MagikeyboardService.kt (94%) create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationParameters.kt rename app/src/main/java/com/kunzisoft/keepass/credentialprovider/{ => passkey}/data/PublicKeyCredentialRequestOptions.kt (63%) create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialUsageParameters.kt rename app/src/main/java/com/kunzisoft/keepass/credentialprovider/{ => passkey}/util/AppRelyingPartyRelation.kt (90%) rename app/src/main/java/com/kunzisoft/keepass/credentialprovider/{ => passkey}/util/Base64Helper.kt (88%) rename app/src/main/java/com/kunzisoft/keepass/credentialprovider/{ => passkey}/util/JsonHelper.kt (95%) rename app/src/main/java/com/kunzisoft/keepass/credentialprovider/{ => passkey}/util/OriginHelper.kt (61%) create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt delete mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/service/KeePassDXCredentialProviderService.kt delete mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/DatabaseHelper.kt delete mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/IntentHelper.kt delete mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/PasskeyConverter.kt create mode 100644 database/src/main/java/com/kunzisoft/keepass/model/EntryInfoPasskey.kt create mode 100644 database/src/main/java/com/kunzisoft/keepass/model/Passkey.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 72cc4bc09..53a345779 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -161,7 +161,7 @@ @@ -175,7 +175,7 @@ android:name="com.kunzisoft.keepass.hardware.HardwareKeyActivity" android:theme="@style/Theme.Transparent" /> @@ -201,19 +201,13 @@ - - - - @@ -253,7 +247,7 @@ @@ -263,12 +257,11 @@ - diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt index 75bc8c5f0..90c5a6947 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/EntryActivity.kt @@ -23,7 +23,6 @@ import android.content.Intent import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.net.Uri -import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper @@ -38,13 +37,10 @@ import androidx.activity.result.ActivityResultLauncher import androidx.activity.viewModels import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.content.ContextCompat -import androidx.core.content.res.ResourcesCompat import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat import androidx.core.graphics.ColorUtils import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.appbar.AppBarLayout @@ -54,7 +50,7 @@ import com.google.android.material.tabs.TabLayout import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.fragments.EntryFragment import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode +import com.kunzisoft.keepass.credentialprovider.SpecialMode import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity import com.kunzisoft.keepass.adapters.TagsAdapter import com.kunzisoft.keepass.database.ContextualDatabase @@ -62,7 +58,7 @@ import com.kunzisoft.keepass.database.element.Attachment import com.kunzisoft.keepass.database.element.icon.IconImage import com.kunzisoft.keepass.database.element.node.NodeId import com.kunzisoft.keepass.education.EntryActivityEducation -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService +import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.model.EntryAttachmentState import com.kunzisoft.keepass.otp.OtpType import com.kunzisoft.keepass.services.AttachmentFileNotificationService diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt index ce9a54fd0..d615c1da1 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/EntryEditActivity.kt @@ -55,12 +55,15 @@ import com.kunzisoft.keepass.activities.dialogs.FileTooBigDialogFragment.Compani import com.kunzisoft.keepass.activities.dialogs.ReplaceFileDialogFragment import com.kunzisoft.keepass.activities.dialogs.SetOTPDialogFragment import com.kunzisoft.keepass.activities.fragments.EntryEditFragment -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity import com.kunzisoft.keepass.adapters.TemplatesSelectorAdapter -import com.kunzisoft.keepass.autofill.AutofillComponent -import com.kunzisoft.keepass.autofill.AutofillHelper +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper +import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyResponseAndSetResult import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.element.Attachment import com.kunzisoft.keepass.database.element.DateInstant @@ -70,7 +73,6 @@ import com.kunzisoft.keepass.database.element.node.Node import com.kunzisoft.keepass.database.element.node.NodeId import com.kunzisoft.keepass.database.element.template.Template import com.kunzisoft.keepass.education.EntryEditActivityEducation -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.model.AttachmentState import com.kunzisoft.keepass.model.DataTime import com.kunzisoft.keepass.model.EntryAttachmentState @@ -376,18 +378,25 @@ class EntryEditActivity : DatabaseLockActivity(), // Don't wait for saving if it's to provide autofill mDatabase?.let { database -> - EntrySelectionHelper.doSpecialAction(intent, - {}, - {}, - {}, - { + EntrySelectionHelper.doSpecialAction( + intent = intent, + defaultAction = {}, + searchAction = {}, + saveAction = {}, + keyboardSelectionAction = { entryValidatedForKeyboardSelection(database, entrySave.newEntry) }, - { _, _ -> + autofillSelectionAction = { _, _ -> entryValidatedForAutofillSelection(database, entrySave.newEntry) }, - { + autofillRegistrationAction = { entryValidatedForAutofillRegistration(entrySave.newEntry) + }, + passkeySelectionAction = { + entryValidatedForPasskeySelection(database, entrySave.newEntry) + }, + passkeyRegistrationAction = { + entryValidatedForPasskeyRegistration(database, entrySave.newEntry) } ) } @@ -430,25 +439,32 @@ class EntryEditActivity : DatabaseLockActivity(), } if (newNodes.size == 1) { (newNodes[0] as? Entry?)?.let { entry -> - EntrySelectionHelper.doSpecialAction(intent, - { + EntrySelectionHelper.doSpecialAction( + intent = intent, + defaultAction = { // Finish naturally finishForEntryResult(entry) }, - { + searchAction = { // Nothing when search retrieved }, - { + saveAction = { entryValidatedForSave(entry) }, - { + keyboardSelectionAction = { entryValidatedForKeyboardSelection(database, entry) }, - { _, _ -> + autofillSelectionAction = { _, _ -> entryValidatedForAutofillSelection(database, entry) }, - { + autofillRegistrationAction = { entryValidatedForAutofillRegistration(entry) + }, + passkeySelectionAction = { + entryValidatedForPasskeySelection(database, entry) + }, + passkeyRegistrationAction = { + entryValidatedForPasskeyRegistration(database, entry) } ) } @@ -488,9 +504,33 @@ class EntryEditActivity : DatabaseLockActivity(), onValidateSpecialMode() } - private fun entryValidatedForAutofillRegistration(entry: Entry) { + private fun entryValidatedForPasskeySelection(database: ContextualDatabase, entry: Entry) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + this.buildPasskeyResponseAndSetResult( + entryInfo = entry.getEntryInfo(database) + ) + } + onValidateSpecialMode() + } + + private fun entryValidatedForAutofillRegistration(entry: Entry) { + //if (isIntentSender()) { + // TODO Autofill Callback #765 + //} + onValidateSpecialMode() + if (!isIntentSender()) { + finishForEntryResult(entry) + } + } + + private fun entryValidatedForPasskeyRegistration(database: ContextualDatabase, entry: Entry) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + this.buildPasskeyResponseAndSetResult( + entryInfo = entry.getEntryInfo(database), + extras = buildEntryResult(entry) // To update the previous screen + ) + } onValidateSpecialMode() - finishForEntryResult(entry) } override fun onResume() { @@ -742,12 +782,17 @@ class EntryEditActivity : DatabaseLockActivity(), } } + private fun buildEntryResult(entry: Entry): Bundle { + return Bundle().apply { + putParcelable(ADD_OR_UPDATE_ENTRY_KEY, entry.nodeId) + } + } + private fun finishForEntryResult(entry: Entry) { // Assign entry callback as a result try { - val bundle = Bundle() + val bundle = buildEntryResult(entry) val intentEntry = Intent() - bundle.putParcelable(ADD_OR_UPDATE_ENTRY_KEY, entry.nodeId) intentEntry.putExtras(bundle) setResult(Activity.RESULT_OK, intentEntry) super.finish() @@ -892,7 +937,7 @@ class EntryEditActivity : DatabaseLockActivity(), if (TimeoutHelper.checkTimeAndLockIfTimeout(activity)) { val intent = Intent(activity, EntryEditActivity::class.java) intent.putExtra(KEY_PARENT, groupId) - AutofillHelper.startActivityForAutofillResult( + EntrySelectionHelper.startActivityForAutofillSelectionModeResult( activity, intent, activityResultLauncher, @@ -903,21 +948,48 @@ class EntryEditActivity : DatabaseLockActivity(), } } + /** + * Launch EntryEditActivity to add a new passkey entry + */ + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + fun launchForPasskeySelectionResult(context: Context, + database: ContextualDatabase, + activityResultLauncher: ActivityResultLauncher?, + groupId: NodeId<*>, + searchInfo: SearchInfo? = null) { + if (database.loaded && !database.isReadOnly) { + if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) { + val intent = Intent(context, EntryEditActivity::class.java) + intent.putExtra(KEY_PARENT, groupId) + EntrySelectionHelper.startActivityForPasskeySelectionModeResult( + context, + intent, + activityResultLauncher, + searchInfo + ) + } + } + } + /** * Launch EntryEditActivity to register an updated entry (from autofill) */ fun launchToUpdateForRegistration(context: Context, database: ContextualDatabase, + activityResultLauncher: ActivityResultLauncher?, entryId: NodeId, - registerInfo: RegisterInfo? = null) { + registerInfo: RegisterInfo?, + typeMode: TypeMode) { if (database.loaded && !database.isReadOnly) { if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) { val intent = Intent(context, EntryEditActivity::class.java) intent.putExtra(KEY_ENTRY, entryId) EntrySelectionHelper.startActivityForRegistrationModeResult( context, + activityResultLauncher, intent, - registerInfo + registerInfo, + typeMode ) } } @@ -928,16 +1000,20 @@ class EntryEditActivity : DatabaseLockActivity(), */ fun launchToCreateForRegistration(context: Context, database: ContextualDatabase, + activityResultLauncher: ActivityResultLauncher?, groupId: NodeId<*>, - registerInfo: RegisterInfo? = null) { + registerInfo: RegisterInfo? = null, + typeMode: TypeMode) { if (database.loaded && !database.isReadOnly) { if (TimeoutHelper.checkTimeAndLockIfTimeout(context)) { val intent = Intent(context, EntryEditActivity::class.java) intent.putExtra(KEY_PARENT, groupId) EntrySelectionHelper.startActivityForRegistrationModeResult( context, + activityResultLauncher, intent, - registerInfo + registerInfo, + typeMode ) } } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt index cfafd0853..5db3f3cd6 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/FileDatabaseSelectActivity.kt @@ -44,15 +44,16 @@ import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.snackbar.Snackbar import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.dialogs.SetMainCredentialDialogFragment -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode +import com.kunzisoft.keepass.credentialprovider.SpecialMode import com.kunzisoft.keepass.activities.helpers.setOpenDocumentClickListener import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity import com.kunzisoft.keepass.adapters.FileDatabaseHistoryAdapter import com.kunzisoft.keepass.app.database.FileDatabaseHistoryAction -import com.kunzisoft.keepass.autofill.AutofillComponent -import com.kunzisoft.keepass.autofill.AutofillHelper +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.education.FileDatabaseSelectActivityEducation @@ -99,10 +100,8 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), private var mExternalFileHelper: ExternalFileHelper? = null - private var mAutofillActivityResultLauncher: ActivityResultLauncher? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - AutofillHelper.buildActivityResultLauncher(this) - else null + private var mCredentialActivityResultLauncher: ActivityResultLauncher? = + this.buildActivityResultLauncher() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -299,7 +298,7 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), }, { onCancelSpecialMode() }, { onLaunchActivitySpecialMode() }, - mAutofillActivityResultLauncher) + mCredentialActivityResultLauncher) } private fun launchGroupActivityIfLoaded(database: ContextualDatabase) { @@ -309,7 +308,7 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), { onValidateSpecialMode() }, { onCancelSpecialMode() }, { onLaunchActivitySpecialMode() }, - mAutofillActivityResultLauncher) + mCredentialActivityResultLauncher) } } @@ -493,23 +492,46 @@ class FileDatabaseSelectActivity : DatabaseModeActivity(), activityResultLauncher: ActivityResultLauncher?, autofillComponent: AutofillComponent, searchInfo: SearchInfo? = null) { - AutofillHelper.startActivityForAutofillResult(activity, + EntrySelectionHelper.startActivityForAutofillSelectionModeResult(activity, Intent(activity, FileDatabaseSelectActivity::class.java), activityResultLauncher, autofillComponent, searchInfo) } + /* + * ------------------------- + * Passkey Launch + * ------------------------- + */ + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + fun launchForPasskeySelectionResult(activity: Activity, + activityResultLauncher: ActivityResultLauncher?, + searchInfo: SearchInfo? = null) { + EntrySelectionHelper.startActivityForPasskeySelectionModeResult( + activity, + Intent(activity, FileDatabaseSelectActivity::class.java), + activityResultLauncher, + searchInfo + ) + } + /* * ------------------------- * Registration Launch * ------------------------- */ fun launchForRegistration(context: Context, - registerInfo: RegisterInfo? = null) { - EntrySelectionHelper.startActivityForRegistrationModeResult(context, - Intent(context, FileDatabaseSelectActivity::class.java), - registerInfo) + activityResultLauncher: ActivityResultLauncher?, + registerInfo: RegisterInfo? = null, + typeMode: TypeMode) { + EntrySelectionHelper.startActivityForRegistrationModeResult( + context, + activityResultLauncher, + Intent(context, FileDatabaseSelectActivity::class.java), + registerInfo, + typeMode + ) } } } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt index 06b28ac27..70fed5332 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt @@ -63,13 +63,15 @@ import com.kunzisoft.keepass.activities.dialogs.GroupEditDialogFragment import com.kunzisoft.keepass.activities.dialogs.MainCredentialDialogFragment import com.kunzisoft.keepass.activities.dialogs.SortDialogFragment import com.kunzisoft.keepass.activities.fragments.GroupFragment -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode +import com.kunzisoft.keepass.credentialprovider.SpecialMode import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity import com.kunzisoft.keepass.adapters.BreadcrumbAdapter -import com.kunzisoft.keepass.autofill.AutofillComponent -import com.kunzisoft.keepass.autofill.AutofillHelper +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.database.element.DateInstant @@ -83,7 +85,8 @@ import com.kunzisoft.keepass.database.element.node.Type import com.kunzisoft.keepass.database.helper.SearchHelper import com.kunzisoft.keepass.database.search.SearchParameters import com.kunzisoft.keepass.education.GroupActivityEducation -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService +import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyResponseAndSetResult import com.kunzisoft.keepass.model.DataTime import com.kunzisoft.keepass.model.GroupInfo import com.kunzisoft.keepass.model.RegisterInfo @@ -264,10 +267,8 @@ class GroupActivity : DatabaseLockActivity(), mGroupEditViewModel.selectIcon(icon) } - private var mAutofillActivityResultLauncher: ActivityResultLauncher? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - AutofillHelper.buildActivityResultLauncher(this) - else null + private var mCredentialActivityResultLauncher: ActivityResultLauncher? = + this.buildActivityResultLauncher() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -484,59 +485,87 @@ class GroupActivity : DatabaseLockActivity(), addNodeButtonView?.setAddEntryClickListener { mDatabase?.let { database -> mMainGroup?.let { currentGroup -> - EntrySelectionHelper.doSpecialAction(intent, - { + EntrySelectionHelper.doSpecialAction( + intent = intent, + defaultAction = { mMainGroup?.nodeId?.let { currentParentGroupId -> EntryEditActivity.launchToCreate( - this@GroupActivity, - database, - currentParentGroupId, - mEntryActivityResultLauncher + activity = this@GroupActivity, + database = database, + groupId = currentParentGroupId, + activityResultLauncher = mEntryActivityResultLauncher ) } }, - { + searchAction = { // Search not used }, - { searchInfo -> + saveAction = { searchInfo -> EntryEditActivity.launchToCreateForSave( - this@GroupActivity, - database, - currentGroup.nodeId, - searchInfo + context = this@GroupActivity, + database = database, + groupId = currentGroup.nodeId, + searchInfo = searchInfo ) onLaunchActivitySpecialMode() }, - { searchInfo -> + keyboardSelectionAction = { searchInfo -> EntryEditActivity.launchForKeyboardSelectionResult( - this@GroupActivity, - database, - currentGroup.nodeId, - searchInfo + context = this@GroupActivity, + database = database, + groupId = currentGroup.nodeId, + searchInfo = searchInfo ) onLaunchActivitySpecialMode() }, - { searchInfo, autofillComponent -> + autofillSelectionAction = { searchInfo, autofillComponent -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { EntryEditActivity.launchForAutofillResult( - this@GroupActivity, - database, - mAutofillActivityResultLauncher, - autofillComponent, - currentGroup.nodeId, - searchInfo + activity = this@GroupActivity, + database = database, + activityResultLauncher = mCredentialActivityResultLauncher, + autofillComponent = autofillComponent, + groupId = currentGroup.nodeId, + searchInfo = searchInfo ) onLaunchActivitySpecialMode() } else { onCancelSpecialMode() } }, - { searchInfo -> + autofillRegistrationAction = { registerInfo -> EntryEditActivity.launchToCreateForRegistration( - this@GroupActivity, - database, - currentGroup.nodeId, - searchInfo + context = this@GroupActivity, + database = database, + activityResultLauncher = null, + groupId = currentGroup.nodeId, + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) + onLaunchActivitySpecialMode() + }, + passkeySelectionAction = { searchInfo -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + EntryEditActivity.launchForPasskeySelectionResult( + context = this@GroupActivity, + database = database, + activityResultLauncher = mCredentialActivityResultLauncher, + groupId = currentGroup.nodeId, + searchInfo = searchInfo, + ) + onLaunchActivitySpecialMode() + } else { + onCancelSpecialMode() + } + }, + passkeyRegistrationAction = { registerInfo -> + EntryEditActivity.launchToCreateForRegistration( + context = this@GroupActivity, + database = database, + activityResultLauncher = mCredentialActivityResultLauncher, + groupId = currentGroup.nodeId, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY ) onLaunchActivitySpecialMode() } @@ -679,30 +708,40 @@ class GroupActivity : DatabaseLockActivity(), when (actionTask) { ACTION_DATABASE_UPDATE_ENTRY_TASK -> { if (result.isSuccess) { - EntrySelectionHelper.doSpecialAction(intent, - { + EntrySelectionHelper.doSpecialAction( + intent = intent, + defaultAction = { // Standard not used after task }, - { + searchAction = { // Search not used }, - { + saveAction = { // Save not used }, - { + keyboardSelectionAction = { // Keyboard selection entry?.let { entrySelectedForKeyboardSelection(database, it) } }, - { _, _ -> + autofillSelectionAction = { _, _ -> // Autofill selection entry?.let { entrySelectedForAutofillSelection(database, it) } }, - { + autofillRegistrationAction = { // Not use + }, + passkeySelectionAction = { + // Passkey selection + entry?.let { + entrySelectedForPasskeySelection(database, it) + } + }, + passkeyRegistrationAction = { + // TODO Passkey Registration } ) } @@ -846,27 +885,28 @@ class GroupActivity : DatabaseLockActivity(), Type.ENTRY -> try { val entryVersioned = node as Entry - EntrySelectionHelper.doSpecialAction(intent, - { + EntrySelectionHelper.doSpecialAction( + intent = intent, + defaultAction = { EntryActivity.launch( - this@GroupActivity, - database, - entryVersioned.nodeId, - mEntryActivityResultLauncher + activity = this@GroupActivity, + database = database, + entryId = entryVersioned.nodeId, + activityResultLauncher = mEntryActivityResultLauncher ) // Do not reload group here }, - { + searchAction = { // Nothing here, a search is simply performed }, - { searchInfo -> + saveAction = { searchInfo -> if (!database.isReadOnly) { entrySelectedForSave(database, entryVersioned, searchInfo) loadGroup() } else finish() }, - { searchInfo -> + keyboardSelectionAction = { searchInfo -> if (!database.isReadOnly && searchInfo != null && PreferencesUtil.isKeyboardSaveSearchInfoEnable(this@GroupActivity) @@ -876,7 +916,7 @@ class GroupActivity : DatabaseLockActivity(), entrySelectedForKeyboardSelection(database, entryVersioned) loadGroup() }, - { searchInfo, _ -> + autofillSelectionAction = { searchInfo, _ -> if (!database.isReadOnly && searchInfo != null && PreferencesUtil.isAutofillSaveSearchInfoEnable(this@GroupActivity) @@ -886,9 +926,39 @@ class GroupActivity : DatabaseLockActivity(), entrySelectedForAutofillSelection(database, entryVersioned) loadGroup() }, - { registerInfo -> + autofillRegistrationAction = { registerInfo -> if (!database.isReadOnly) { - entrySelectedForRegistration(database, entryVersioned, registerInfo) + entrySelectedForRegistration( + database = database, + entry = entryVersioned, + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL, + activityResultLauncher = null // TODO Result launcher autofill #765 + ) + loadGroup() + } else + finish() + }, + passkeySelectionAction = { searchInfo -> + if (!database.isReadOnly + && searchInfo != null + // TODO Passkey setting && PreferencesUtil.isAutofillSaveSearchInfoEnable(this@GroupActivity) + ) { + updateEntryWithSearchInfo(database, entryVersioned, searchInfo) + } + entrySelectedForPasskeySelection(database, entryVersioned) + loadGroup() + }, + passkeyRegistrationAction = { registerInfo -> + if (!database.isReadOnly) { + // TODO Passkey setting && PreferencesUtil.isAutofillOverwriteEnable(this@GroupActivity) + entrySelectedForRegistration( + database = database, + entry = entryVersioned, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY, + activityResultLauncher = mCredentialActivityResultLauncher + ) loadGroup() } else finish() @@ -934,18 +1004,33 @@ class GroupActivity : DatabaseLockActivity(), onValidateSpecialMode() } + private fun entrySelectedForPasskeySelection(database: ContextualDatabase, entry: Entry) { + removeSearch() + // Build response with the entry selected + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + buildPasskeyResponseAndSetResult( + entryInfo = entry.getEntryInfo(database) + ) + } + onValidateSpecialMode() + } + private fun entrySelectedForRegistration( database: ContextualDatabase, entry: Entry, - registerInfo: RegisterInfo? + activityResultLauncher: ActivityResultLauncher?, + registerInfo: RegisterInfo?, + typeMode: TypeMode ) { removeSearch() // Registration to update the entry EntryEditActivity.launchToUpdateForRegistration( - this@GroupActivity, - database, - entry.nodeId, - registerInfo + context = this@GroupActivity, + database = database, + activityResultLauncher = activityResultLauncher, + entryId = entry.nodeId, + registerInfo = registerInfo, + typeMode = typeMode ) onLaunchActivitySpecialMode() } @@ -1569,19 +1654,19 @@ class GroupActivity : DatabaseLockActivity(), * ------------------------- */ @RequiresApi(api = Build.VERSION_CODES.O) - fun launchForAutofillResult(activity: AppCompatActivity, - database: ContextualDatabase, - activityResultLaunch: ActivityResultLauncher?, - autofillComponent: AutofillComponent, - searchInfo: SearchInfo? = null, - autoSearch: Boolean = false) { + fun launchForAutofillSelectionResult(activity: AppCompatActivity, + database: ContextualDatabase, + activityResultLauncher: ActivityResultLauncher?, + autofillComponent: AutofillComponent, + searchInfo: SearchInfo? = null, + autoSearch: Boolean = false) { if (database.loaded) { checkTimeAndBuildIntent(activity, null) { intent -> intent.putExtra(AUTO_SEARCH_KEY, autoSearch) - AutofillHelper.startActivityForAutofillResult( + EntrySelectionHelper.startActivityForAutofillSelectionModeResult( activity, intent, - activityResultLaunch, + activityResultLauncher, autofillComponent, searchInfo ) @@ -1589,21 +1674,49 @@ class GroupActivity : DatabaseLockActivity(), } } + /* + * ------------------------- + * Passkey Launch + * ------------------------- + */ + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + fun launchForPasskeySelectionResult(context: Context, + database: ContextualDatabase, + activityResultLauncher: ActivityResultLauncher?, + searchInfo: SearchInfo? = null, + autoSearch: Boolean = false) { + if (database.loaded) { + checkTimeAndBuildIntent(context, null) { intent -> + intent.putExtra(AUTO_SEARCH_KEY, autoSearch) + EntrySelectionHelper.startActivityForPasskeySelectionModeResult( + context, + intent, + activityResultLauncher, + searchInfo + ) + } + } + } + /* * ------------------------- * Registration Launch * ------------------------- */ fun launchForRegistration(context: Context, + activityResultLauncher: ActivityResultLauncher?, database: ContextualDatabase, - registerInfo: RegisterInfo? = null) { + registerInfo: RegisterInfo? = null, + typeMode: TypeMode) { if (database.loaded && !database.isReadOnly) { checkTimeAndBuildIntent(context, null) { intent -> intent.putExtra(AUTO_SEARCH_KEY, false) EntrySelectionHelper.startActivityForRegistrationModeResult( context, + activityResultLauncher, intent, - registerInfo + registerInfo, + typeMode ) } } @@ -1619,153 +1732,231 @@ class GroupActivity : DatabaseLockActivity(), onValidateSpecialMode: () -> Unit, onCancelSpecialMode: () -> Unit, onLaunchActivitySpecialMode: () -> Unit, - autofillActivityResultLauncher: ActivityResultLauncher?) { - EntrySelectionHelper.doSpecialAction(activity.intent, - { - // Default action - launch( - activity, + activityResultLauncher: ActivityResultLauncher?) { + EntrySelectionHelper.doSpecialAction( + intent = activity.intent, + defaultAction = { + // Default action + launch( + activity, + database, + true + ) + }, + searchAction = { searchInfo -> + // Search action + if (database.loaded) { + launchForSearchResult(activity, database, - true - ) - }, - { searchInfo -> - // Search action - if (database.loaded) { - launchForSearchResult(activity, + searchInfo, + true) + onLaunchActivitySpecialMode() + } else { + // Simply close if database not opened + onCancelSpecialMode() + } + }, + saveAction = { searchInfo -> + // Save info + if (database.loaded) { + if (!database.isReadOnly) { + launchForSaveResult( + activity, database, searchInfo, - true) + false + ) onLaunchActivitySpecialMode() } else { - // Simply close if database not opened + Toast.makeText( + activity.applicationContext, + R.string.autofill_read_only_save, + Toast.LENGTH_LONG + ) + .show() onCancelSpecialMode() } - }, - { searchInfo -> - // Save info - if (database.loaded) { - if (!database.isReadOnly) { - launchForSaveResult( - activity, - database, - searchInfo, - false - ) - onLaunchActivitySpecialMode() - } else { - Toast.makeText( - activity.applicationContext, - R.string.autofill_read_only_save, - Toast.LENGTH_LONG - ) - .show() - onCancelSpecialMode() - } - } - }, - { searchInfo -> - // Keyboard selection - SearchHelper.checkAutoSearchInfo(activity, - database, - searchInfo, - { _, items -> - MagikeyboardService.performSelection( - items, - { entryInfo -> - // Keyboard populated - MagikeyboardService.populateKeyboardAndMoveAppToBackground( - activity, - entryInfo - ) - onValidateSpecialMode() - }, - { autoSearch -> - launchForKeyboardSelectionResult(activity, - database, - searchInfo, - autoSearch) - onLaunchActivitySpecialMode() - } + } + }, + keyboardSelectionAction = { searchInfo -> + // Keyboard selection + SearchHelper.checkAutoSearchInfo( + context = activity, + database = database, + searchInfo = searchInfo, + onItemsFound = { _, items -> + MagikeyboardService.performSelection( + items, + { entryInfo -> + // Keyboard populated + MagikeyboardService.populateKeyboardAndMoveAppToBackground( + activity, + entryInfo ) + onValidateSpecialMode() }, - { - // Here no search info found, disable auto search + { autoSearch -> launchForKeyboardSelectionResult(activity, database, searchInfo, - false) + autoSearch) onLaunchActivitySpecialMode() - }, - { - // Simply close if database not opened, normally not happened - onCancelSpecialMode() } + ) + }, + onItemNotFound = { + // Here no search info found, disable auto search + launchForKeyboardSelectionResult(activity, + database, + searchInfo, + false) + onLaunchActivitySpecialMode() + }, + onDatabaseClosed = { + // Simply close if database not opened, normally not happened + onCancelSpecialMode() + } + ) + }, + autofillSelectionAction = { searchInfo, autofillComponent -> + // Autofill selection + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + SearchHelper.checkAutoSearchInfo( + context = activity, + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, items -> + // Response is build + AutofillHelper.buildResponseAndSetResult(activity, openedDatabase, items) + onValidateSpecialMode() + }, + onItemNotFound = { + // Here no search info found, disable auto search + launchForAutofillSelectionResult( + activity = activity, + database = database, + autofillComponent = autofillComponent, + searchInfo = searchInfo, + autoSearch = false, + activityResultLauncher = activityResultLauncher) + onLaunchActivitySpecialMode() + }, + onDatabaseClosed = { + // Simply close if database not opened, normally not happened + onCancelSpecialMode() + } ) - }, - { searchInfo, autofillComponent -> - // Autofill selection - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - SearchHelper.checkAutoSearchInfo(activity, - database, - searchInfo, - { openedDatabase, items -> - // Response is build - AutofillHelper.buildResponseAndSetResult(activity, openedDatabase, items) + } else { + onCancelSpecialMode() + } + }, + autofillRegistrationAction = { registerInfo -> + // Autofill registration + if (!database.isReadOnly) { + SearchHelper.checkAutoSearchInfo( + context = activity, + database = database, + searchInfo = registerInfo?.searchInfo, + onItemsFound = { _, _ -> + // No auto search, it's a registration + launchForRegistration( + context = activity, + activityResultLauncher = null, // TODO Autofill result Launcher #765 + database = database, + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) + onLaunchActivitySpecialMode() + }, + onItemNotFound = { + // Here no search info found, disable auto search + launchForRegistration( + context = activity, + activityResultLauncher = null, // TODO Autofill result Launcher #765 + database = database, + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) + onLaunchActivitySpecialMode() + }, + onDatabaseClosed = { + // Simply close if database not opened, normally not happened + onCancelSpecialMode() + } + ) + } else { + Toast.makeText(activity.applicationContext, + R.string.autofill_read_only_save, + Toast.LENGTH_LONG) + .show() + onCancelSpecialMode() + } + }, + passkeySelectionAction = { searchInfo -> + // Passkey selection + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + SearchHelper.checkAutoSearchInfo( + context = activity, + database = database, + searchInfo = searchInfo, + onItemsFound = { _, items -> + // Response is build + EntrySelectionHelper.performSelection( + items = items, + actionPopulateCredentialProvider = { entryInfo -> + activity.buildPasskeyResponseAndSetResult(entryInfo) onValidateSpecialMode() }, - { - // Here no search info found, disable auto search - launchForAutofillResult(activity, - database, - autofillActivityResultLauncher, - autofillComponent, - searchInfo, - false) + actionEntrySelection = { + launchForPasskeySelectionResult( + context = activity, + database = database, + searchInfo = searchInfo, + activityResultLauncher = activityResultLauncher + ) onLaunchActivitySpecialMode() - }, - { - // Simply close if database not opened, normally not happened - onCancelSpecialMode() } - ) - } else { - onCancelSpecialMode() - } - }, - { registerInfo -> - // Autofill registration - if (!database.isReadOnly) { - SearchHelper.checkAutoSearchInfo(activity, - database, - registerInfo?.searchInfo, - { _, _ -> - // No auto search, it's a registration - launchForRegistration(activity, - database, - registerInfo) - onLaunchActivitySpecialMode() - }, - { - // Here no search info found, disable auto search - launchForRegistration(activity, - database, - registerInfo) - onLaunchActivitySpecialMode() - }, - { - // Simply close if database not opened, normally not happened - onCancelSpecialMode() - } - ) - } else { - Toast.makeText(activity.applicationContext, - R.string.autofill_read_only_save, - Toast.LENGTH_LONG) - .show() - onCancelSpecialMode() - } - }) + ) + }, + onItemNotFound = { + // Here no search info found, disable auto search + launchForPasskeySelectionResult( + context = activity, + database = database, + searchInfo = searchInfo, + activityResultLauncher = activityResultLauncher + ) + onLaunchActivitySpecialMode() + }, + onDatabaseClosed = { + // Simply close if database not opened, normally not happened + onCancelSpecialMode() + } + ) + } else { + onCancelSpecialMode() + } + }, + passkeyRegistrationAction = { registerInfo -> + // Passkey registration + if (!database.isReadOnly) { + launchForRegistration( + context = activity, + activityResultLauncher = activityResultLauncher, + database = database, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + onLaunchActivitySpecialMode() + } else { + Toast.makeText(activity.applicationContext, + R.string.autofill_read_only_save, + Toast.LENGTH_LONG) + .show() + onCancelSpecialMode() + } + } + ) } } } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt index 2748c34ea..892eab097 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/MainCredentialActivity.kt @@ -46,15 +46,16 @@ import androidx.fragment.app.commit import com.google.android.material.snackbar.Snackbar import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.dialogs.DuplicateUuidDialog -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper import com.kunzisoft.keepass.activities.helpers.ExternalFileHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode import com.kunzisoft.keepass.activities.legacy.DatabaseLockActivity import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity -import com.kunzisoft.keepass.autofill.AutofillComponent -import com.kunzisoft.keepass.autofill.AutofillHelper import com.kunzisoft.keepass.biometric.AdvancedUnlockFragment import com.kunzisoft.keepass.biometric.AdvancedUnlockManager +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException @@ -113,10 +114,8 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu private var mReadOnly: Boolean = false private var mForceReadOnly: Boolean = false - private var mAutofillActivityResultLauncher: ActivityResultLauncher? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - AutofillHelper.buildActivityResultLauncher(this) - else null + private var mCredentialActivityResultLauncher: ActivityResultLauncher? = + this.buildActivityResultLauncher() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -395,7 +394,7 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu { onValidateSpecialMode() }, { onCancelSpecialMode() }, { onLaunchActivitySpecialMode() }, - mAutofillActivityResultLauncher + mCredentialActivityResultLauncher ) } } @@ -806,14 +805,14 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu @RequiresApi(api = Build.VERSION_CODES.O) @Throws(FileNotFoundException::class) fun launchForAutofillResult(activity: AppCompatActivity, + activityResultLauncher: ActivityResultLauncher?, databaseFile: Uri, keyFile: Uri?, hardwareKey: HardwareKey?, - activityResultLauncher: ActivityResultLauncher?, autofillComponent: AutofillComponent, searchInfo: SearchInfo?) { buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent -> - AutofillHelper.startActivityForAutofillResult( + EntrySelectionHelper.startActivityForAutofillSelectionModeResult( activity, intent, activityResultLauncher, @@ -822,21 +821,51 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu } } + /* + * ------------------------- + * Passkey Launch + * ------------------------- + */ + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + @Throws(FileNotFoundException::class) + fun launchForPasskeyResult(activity: Activity, + activityResultLauncher: ActivityResultLauncher?, + databaseFile: Uri, + keyFile: Uri?, + hardwareKey: HardwareKey?, + searchInfo: SearchInfo?) { + buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent -> + EntrySelectionHelper.startActivityForPasskeySelectionModeResult( + activity, + intent, + activityResultLauncher, + searchInfo + ) + } + } + /* * ------------------------- * Registration Launch * ------------------------- */ - fun launchForRegistration(activity: Activity, - databaseFile: Uri, - keyFile: Uri?, - hardwareKey: HardwareKey?, - registerInfo: RegisterInfo?) { + fun launchForRegistration( + activity: Activity, + activityResultLauncher: ActivityResultLauncher?, + databaseFile: Uri, + keyFile: Uri?, + hardwareKey: HardwareKey?, + typeMode: TypeMode, + registerInfo: RegisterInfo? + ) { buildAndLaunchIntent(activity, databaseFile, keyFile, hardwareKey) { intent -> EntrySelectionHelper.startActivityForRegistrationModeResult( - activity, - intent, - registerInfo) + context = activity, + activityResultLauncher = activityResultLauncher, + intent = intent, + typeMode = typeMode, + registerInfo = registerInfo + ) } } @@ -852,74 +881,104 @@ class MainCredentialActivity : DatabaseModeActivity(), AdvancedUnlockFragment.Bu fileNoFoundAction: (exception: FileNotFoundException) -> Unit, onCancelSpecialMode: () -> Unit, onLaunchActivitySpecialMode: () -> Unit, - autofillActivityResultLauncher: ActivityResultLauncher?) { + activityResultLauncher: ActivityResultLauncher?) { try { - EntrySelectionHelper.doSpecialAction(activity.intent, - { - launch( - activity, - databaseUri, - keyFile, - hardwareKey - ) - }, - { searchInfo -> // Search Action - launchForSearchResult( - activity, - databaseUri, - keyFile, - hardwareKey, - searchInfo - ) - onLaunchActivitySpecialMode() - }, - { searchInfo -> // Save Action - launchForSaveResult( - activity, - databaseUri, - keyFile, - hardwareKey, - searchInfo - ) - onLaunchActivitySpecialMode() - }, - { searchInfo -> // Keyboard Selection Action - launchForKeyboardResult( - activity, - databaseUri, - keyFile, - hardwareKey, - searchInfo - ) - onLaunchActivitySpecialMode() - }, - { searchInfo, autofillComponent -> // Autofill Selection Action - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - launchForAutofillResult( - activity, - databaseUri, - keyFile, - hardwareKey, - autofillActivityResultLauncher, - autofillComponent, - searchInfo - ) - onLaunchActivitySpecialMode() - } else { - onCancelSpecialMode() - } - }, - { registerInfo -> // Registration Action - launchForRegistration( - activity, - databaseUri, - keyFile, - hardwareKey, - registerInfo + EntrySelectionHelper.doSpecialAction( + intent = activity.intent, + defaultAction = { + launch( + activity = activity, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey + ) + }, + searchAction = { searchInfo -> + launchForSearchResult( + activity = activity, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + searchInfo = searchInfo + ) + onLaunchActivitySpecialMode() + }, + saveAction = { searchInfo -> + launchForSaveResult( + activity = activity, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + searchInfo = searchInfo + ) + onLaunchActivitySpecialMode() + }, + keyboardSelectionAction = { searchInfo -> + launchForKeyboardResult( + activity = activity, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + searchInfo = searchInfo + ) + onLaunchActivitySpecialMode() + }, + autofillSelectionAction = { searchInfo, autofillComponent -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + launchForAutofillResult( + activity = activity, + activityResultLauncher = activityResultLauncher, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + autofillComponent = autofillComponent, + searchInfo = searchInfo ) onLaunchActivitySpecialMode() + } else { + onCancelSpecialMode() } + }, + autofillRegistrationAction = { registerInfo -> + launchForRegistration( + activity = activity, + activityResultLauncher = activityResultLauncher, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + typeMode = TypeMode.AUTOFILL, + registerInfo = registerInfo + ) + onLaunchActivitySpecialMode() + }, + passkeySelectionAction = { searchInfo -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + launchForPasskeyResult( + activity = activity, + activityResultLauncher = activityResultLauncher, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + searchInfo = searchInfo + ) + onLaunchActivitySpecialMode() + } else { + onCancelSpecialMode() + } + }, + passkeyRegistrationAction = { registerInfo -> + launchForRegistration( + activity = activity, + activityResultLauncher = activityResultLauncher, + databaseFile = databaseUri, + keyFile = keyFile, + hardwareKey = hardwareKey, + typeMode = TypeMode.PASSKEY, + registerInfo = registerInfo + ) + onLaunchActivitySpecialMode() + } ) } catch (e: FileNotFoundException) { fileNoFoundAction(e) diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/GroupFragment.kt b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/GroupFragment.kt index 7a7cf5acf..5dd676ea8 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/fragments/GroupFragment.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/fragments/GroupFragment.kt @@ -36,8 +36,8 @@ import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.dialogs.SortDialogFragment -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.SpecialMode import com.kunzisoft.keepass.adapters.NodesAdapter import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.element.Group diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/TypeMode.kt b/app/src/main/java/com/kunzisoft/keepass/activities/helpers/TypeMode.kt deleted file mode 100644 index 2269c0b01..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/TypeMode.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.kunzisoft.keepass.activities.helpers - -enum class TypeMode { - DEFAULT, MAGIKEYBOARD, AUTOFILL -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseLockActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseLockActivity.kt index f129a24ed..9965446d1 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseLockActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseLockActivity.kt @@ -34,8 +34,8 @@ import androidx.appcompat.app.AlertDialog import com.kunzisoft.keepass.R import com.kunzisoft.keepass.activities.dialogs.DeleteNodesDialogFragment import com.kunzisoft.keepass.activities.dialogs.PasswordEncodingDialogFragment -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.SpecialMode import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.MainCredential import com.kunzisoft.keepass.database.element.Entry diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseModeActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseModeActivity.kt index 640682278..84397ab7b 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseModeActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseModeActivity.kt @@ -5,9 +5,10 @@ import android.view.View import android.widget.Toast import androidx.activity.OnBackPressedCallback import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode -import com.kunzisoft.keepass.activities.helpers.TypeMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.isIntentSenderMode +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.TypeMode import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.view.ToolbarSpecial @@ -42,18 +43,15 @@ abstract class DatabaseModeActivity : DatabaseActivity() { /** * Intent sender uses special retains data in callback */ - private fun isIntentSender(): Boolean { - return (mSpecialMode == SpecialMode.SELECTION - && mTypeMode == TypeMode.AUTOFILL) - /* TODO Registration callback #765 - || (mSpecialMode == SpecialMode.REGISTRATION - && mTypeMode == TypeMode.AUTOFILL - && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) - */ + protected fun isIntentSender(): Boolean { + return isIntentSenderMode(mSpecialMode, mTypeMode) } fun onLaunchActivitySpecialMode() { - if (!isIntentSender()) { + // TODO Verify behavior for Autofill Callback #765 + if (isIntentSender()) { + onValidateSpecialMode() + } else { EntrySelectionHelper.removeModesFromIntent(intent) EntrySelectionHelper.removeInfoFromIntent(intent) finish() @@ -136,6 +134,7 @@ abstract class DatabaseModeActivity : DatabaseActivity() { TypeMode.DEFAULT, // Not important because hidden TypeMode.MAGIKEYBOARD -> R.string.magic_keyboard_title TypeMode.AUTOFILL -> R.string.autofill + TypeMode.PASSKEY -> R.string.passkey } title = getString(selectionModeStringId) if (mTypeMode != TypeMode.DEFAULT) diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/EntrySelectionHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt similarity index 55% rename from app/src/main/java/com/kunzisoft/keepass/activities/helpers/EntrySelectionHelper.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt index 31a988772..1be32b30a 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/EntrySelectionHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt @@ -17,17 +17,32 @@ * along with KeePassDX. If not, see . * */ -package com.kunzisoft.keepass.activities.helpers +package com.kunzisoft.keepass.credentialprovider +import android.app.Activity import android.content.Context import android.content.Intent +import android.graphics.drawable.Icon import android.os.Build -import com.kunzisoft.keepass.autofill.AutofillComponent -import com.kunzisoft.keepass.autofill.AutofillHelper +import android.util.Log +import android.widget.RemoteViews +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper.addAutofillComponent +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.model.EntryInfo import com.kunzisoft.keepass.model.RegisterInfo import com.kunzisoft.keepass.model.SearchInfo -import com.kunzisoft.keepass.utils.getParcelableExtraCompat +import com.kunzisoft.keepass.settings.PreferencesUtil +import com.kunzisoft.keepass.utils.LOCK_ACTION import com.kunzisoft.keepass.utils.getEnumExtra +import com.kunzisoft.keepass.utils.getParcelableExtraCompat import com.kunzisoft.keepass.utils.putEnumExtra object EntrySelectionHelper { @@ -37,6 +52,33 @@ object EntrySelectionHelper { private const val KEY_SEARCH_INFO = "com.kunzisoft.keepass.extra.SEARCH_INFO" private const val KEY_REGISTER_INFO = "com.kunzisoft.keepass.extra.REGISTER_INFO" + /** + * Utility method to build a registerForActivityResult, + * Used recursively, close each activity with return data + */ + fun AppCompatActivity.buildActivityResultLauncher( + lockDatabase: Boolean = false, + dataTransformation: (data: Intent?) -> Intent? = { it }, + ): ActivityResultLauncher { + return this.registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { + val resultCode = it.resultCode + if (resultCode == Activity.RESULT_OK) { + this.setResult(resultCode, dataTransformation(it.data)) + } + if (resultCode == Activity.RESULT_CANCELED) { + this.setResult(Activity.RESULT_CANCELED) + } + this.finish() + + if (lockDatabase && PreferencesUtil.isAutofillCloseDatabaseEnable(this)) { + // Close the database + this.sendBroadcast(Intent(LOCK_ACTION)) + } + } + } + fun startActivityForSearchModeResult(context: Context, intent: Intent, searchInfo: SearchInfo) { @@ -66,15 +108,52 @@ object EntrySelectionHelper { context.startActivity(intent) } - fun startActivityForRegistrationModeResult(context: Context, - intent: Intent, - registerInfo: RegisterInfo?) { - addSpecialModeInIntent(intent, SpecialMode.REGISTRATION) - // At the moment, only autofill for registration + /** + * Utility method to start an activity with an Autofill for result + */ + @RequiresApi(Build.VERSION_CODES.O) + fun startActivityForAutofillSelectionModeResult( + context: Context, + intent: Intent, + activityResultLauncher: ActivityResultLauncher?, + autofillComponent: AutofillComponent, + searchInfo: SearchInfo? + ) { + addSpecialModeInIntent(intent, SpecialMode.SELECTION) addTypeModeInIntent(intent, TypeMode.AUTOFILL) + intent.addAutofillComponent(context, autofillComponent) + addSearchInfoInIntent(intent, searchInfo) + activityResultLauncher?.launch(intent) + } + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + fun startActivityForPasskeySelectionModeResult( + context: Context, + intent: Intent, + activityResultLauncher: ActivityResultLauncher?, + searchInfo: SearchInfo? + ) { + addSpecialModeInIntent(intent, SpecialMode.SELECTION) + addTypeModeInIntent(intent, TypeMode.PASSKEY) + addSearchInfoInIntent(intent, searchInfo) + activityResultLauncher?.launch(intent) + } + + fun startActivityForRegistrationModeResult( + context: Context?, + activityResultLauncher: ActivityResultLauncher?, + intent: Intent, + registerInfo: RegisterInfo?, + typeMode: TypeMode + ) { + addSpecialModeInIntent(intent, SpecialMode.REGISTRATION) + addTypeModeInIntent(intent, typeMode) addRegisterInfoInIntent(intent, registerInfo) - intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK - context.startActivity(intent) + if (activityResultLauncher == null) { + intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + context?.startActivity(intent) ?: activityResultLauncher?.launch(intent) ?: + throw IllegalStateException("At least Context or ActivityResultLauncher must not be null") } fun addSearchInfoInIntent(intent: Intent, searchInfo: SearchInfo?) { @@ -103,8 +182,13 @@ object EntrySelectionHelper { } fun addSpecialModeInIntent(intent: Intent, specialMode: SpecialMode) { + // TODO Replace by Intent.addSpecialMode intent.putEnumExtra(KEY_SPECIAL_MODE, specialMode) } + fun Intent.addSpecialMode(specialMode: SpecialMode): Intent { + this.putEnumExtra(KEY_SPECIAL_MODE, specialMode) + return this + } fun retrieveSpecialModeFromIntent(intent: Intent): SpecialMode { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -131,6 +215,17 @@ object EntrySelectionHelper { intent.removeExtra(KEY_TYPE_MODE) } + /** + * Intent sender uses special retains data in callback + */ + fun isIntentSenderMode(specialMode: SpecialMode, typeMode: TypeMode): Boolean { + return (specialMode == SpecialMode.SELECTION + && (typeMode == TypeMode.AUTOFILL || typeMode == TypeMode.PASSKEY)) + // TODO Autofill Registration callback #765 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + || (specialMode == SpecialMode.REGISTRATION + && typeMode == TypeMode.PASSKEY) + } + fun doSpecialAction(intent: Intent, defaultAction: () -> Unit, searchAction: (searchInfo: SearchInfo) -> Unit, @@ -138,7 +233,9 @@ object EntrySelectionHelper { keyboardSelectionAction: (searchInfo: SearchInfo?) -> Unit, autofillSelectionAction: (searchInfo: SearchInfo?, autofillComponent: AutofillComponent) -> Unit, - autofillRegistrationAction: (registerInfo: RegisterInfo?) -> Unit) { + autofillRegistrationAction: (registerInfo: RegisterInfo?) -> Unit, + passkeySelectionAction: (searchInfo: SearchInfo?) -> Unit, + passkeyRegistrationAction: (registerInfo: RegisterInfo?) -> Unit) { when (retrieveSpecialModeFromIntent(intent)) { SpecialMode.DEFAULT -> { @@ -186,6 +283,7 @@ object EntrySelectionHelper { defaultAction.invoke() } TypeMode.MAGIKEYBOARD -> keyboardSelectionAction.invoke(searchInfo) + TypeMode.PASSKEY -> passkeySelectionAction.invoke(searchInfo) else -> { // In this case, error removeModesFromIntent(intent) @@ -202,10 +300,59 @@ object EntrySelectionHelper { } SpecialMode.REGISTRATION -> { val registerInfo: RegisterInfo? = retrieveRegisterInfoFromIntent(intent) - removeModesFromIntent(intent) - removeInfoFromIntent(intent) - autofillRegistrationAction.invoke(registerInfo) + if (!isIntentSenderMode( + specialMode = retrieveSpecialModeFromIntent(intent), + typeMode = retrieveTypeModeFromIntent(intent)) + ) { + removeModesFromIntent(intent) + removeInfoFromIntent(intent) + } + when (retrieveTypeModeFromIntent(intent)) { + TypeMode.AUTOFILL -> { + autofillRegistrationAction.invoke(registerInfo) + } + TypeMode.PASSKEY -> { + passkeyRegistrationAction.invoke(registerInfo) + } + else -> { + // Do other registration type + } + } } } } + + fun performSelection(items: List, + actionPopulateCredentialProvider: (entryInfo: EntryInfo) -> Unit, + actionEntrySelection: (autoSearch: Boolean) -> Unit) { + if (items.size == 1) { + val itemFound = items[0] + actionPopulateCredentialProvider.invoke(itemFound) + } else if (items.size > 1) { + // Select the one we want in the selection + actionEntrySelection.invoke(true) + } else { + // Select an arbitrary one + actionEntrySelection.invoke(false) + } + } + + /** + * Method to assign a drawable to a new icon from a database icon + */ + @RequiresApi(Build.VERSION_CODES.M) + fun EntryInfo.buildIcon( + context: Context, + database: ContextualDatabase + ): Icon? { + try { + database.iconDrawableFactory.getBitmapFromIcon(context, + this.icon, ContextCompat.getColor(context, R.color.green))?.let { bitmap -> + return Icon.createWithBitmap(bitmap) + } + } catch (e: Exception) { + Log.e(RemoteViews::class.java.name, "Unable to assign icon in remote view", e) + } + return null + } } diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/SpecialMode.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/SpecialMode.kt similarity index 65% rename from app/src/main/java/com/kunzisoft/keepass/activities/helpers/SpecialMode.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/SpecialMode.kt index 7b1dbc2ef..e9b11771d 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/helpers/SpecialMode.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/SpecialMode.kt @@ -1,4 +1,4 @@ -package com.kunzisoft.keepass.activities.helpers +package com.kunzisoft.keepass.credentialprovider enum class SpecialMode { DEFAULT, diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/TypeMode.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/TypeMode.kt new file mode 100644 index 000000000..6dc5a443c --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/TypeMode.kt @@ -0,0 +1,5 @@ +package com.kunzisoft.keepass.credentialprovider + +enum class TypeMode { + DEFAULT, MAGIKEYBOARD, AUTOFILL, PASSKEY +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/AutofillLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/AutofillLauncherActivity.kt similarity index 70% rename from app/src/main/java/com/kunzisoft/keepass/activities/AutofillLauncherActivity.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/AutofillLauncherActivity.kt index 39b98abab..a688bb1fd 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/AutofillLauncherActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/AutofillLauncherActivity.kt @@ -17,7 +17,7 @@ * along with KeePassDX. If not, see . * */ -package com.kunzisoft.keepass.activities +package com.kunzisoft.keepass.credentialprovider.activity import android.app.Activity import android.app.PendingIntent @@ -30,13 +30,17 @@ import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.annotation.RequiresApi import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode +import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity +import com.kunzisoft.keepass.activities.GroupActivity +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.SpecialMode import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity -import com.kunzisoft.keepass.autofill.AutofillComponent -import com.kunzisoft.keepass.autofill.AutofillHelper -import com.kunzisoft.keepass.autofill.CompatInlineSuggestionsRequest -import com.kunzisoft.keepass.autofill.KeeAutofillService +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillComponent +import com.kunzisoft.keepass.credentialprovider.autofill.AutofillHelper +import com.kunzisoft.keepass.credentialprovider.autofill.CompatInlineSuggestionsRequest +import com.kunzisoft.keepass.credentialprovider.autofill.KeeAutofillService import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.helper.SearchHelper import com.kunzisoft.keepass.model.RegisterInfo @@ -48,10 +52,8 @@ import com.kunzisoft.keepass.utils.getParcelableExtraCompat @RequiresApi(api = Build.VERSION_CODES.O) class AutofillLauncherActivity : DatabaseModeActivity() { - private var mAutofillActivityResultLauncher: ActivityResultLauncher? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - AutofillHelper.buildActivityResultLauncher(this, true) - else null + private var mCredentialActivityResultLauncher: ActivityResultLauncher? = + this.buildActivityResultLauncher(lockDatabase = true) override fun applyCustomStyle(): Boolean { return false @@ -72,7 +74,9 @@ class AutofillLauncherActivity : DatabaseModeActivity() { // To pass extra inline request var compatInlineSuggestionsRequest: CompatInlineSuggestionsRequest? = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - compatInlineSuggestionsRequest = bundle.getParcelableCompat(KEY_INLINE_SUGGESTION) + compatInlineSuggestionsRequest = bundle.getParcelableCompat( + KEY_INLINE_SUGGESTION + ) } // Build search param bundle.getParcelableCompat(KEY_SEARCH_INFO)?.let { searchInfo -> @@ -102,7 +106,9 @@ class AutofillLauncherActivity : DatabaseModeActivity() { } SpecialMode.REGISTRATION -> { // To register info - val registerInfo = intent.getParcelableExtraCompat(KEY_REGISTER_INFO) + val registerInfo = intent.getParcelableExtraCompat( + KEY_REGISTER_INFO + ) val searchInfo = SearchInfo(registerInfo?.searchInfo) WebDomain.getConcreteWebDomain(this, searchInfo.webDomain) { concreteWebDomain -> searchInfo.webDomain = concreteWebDomain @@ -134,30 +140,35 @@ class AutofillLauncherActivity : DatabaseModeActivity() { finish() } else { // If database is open - SearchHelper.checkAutoSearchInfo(this, - database, - searchInfo, - { openedDatabase, items -> - // Items found - AutofillHelper.buildResponseAndSetResult(this, openedDatabase, items) - finish() - }, - { openedDatabase -> - // Show the database UI to select the entry - GroupActivity.launchForAutofillResult(this, - openedDatabase, - mAutofillActivityResultLauncher, - autofillComponent, - searchInfo, - false) - }, - { - // If database not open - FileDatabaseSelectActivity.launchForAutofillResult(this, - mAutofillActivityResultLauncher, - autofillComponent, - searchInfo) - } + SearchHelper.checkAutoSearchInfo( + context = this, + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, items -> + // Items found + AutofillHelper.buildResponseAndSetResult(this, openedDatabase, items) + finish() + }, + onItemNotFound = { openedDatabase -> + // Show the database UI to select the entry + GroupActivity.launchForAutofillSelectionResult( + this, + openedDatabase, + mCredentialActivityResultLauncher, + autofillComponent, + searchInfo, + false + ) + }, + onDatabaseClosed = { + // If database not open + FileDatabaseSelectActivity.launchForAutofillResult( + this, + mCredentialActivityResultLauncher, + autofillComponent, + searchInfo + ) + } ) } } @@ -174,34 +185,47 @@ class AutofillLauncherActivity : DatabaseModeActivity() { setResult(Activity.RESULT_CANCELED) } else { val readOnly = database?.isReadOnly != false - SearchHelper.checkAutoSearchInfo(this, - database, - searchInfo, - { openedDatabase, _ -> - if (!readOnly) { - // Show the database UI to select the entry - GroupActivity.launchForRegistration(this, - openedDatabase, - registerInfo) - } else { - showReadOnlySaveMessage() - } - }, - { openedDatabase -> - if (!readOnly) { - // Show the database UI to select the entry - GroupActivity.launchForRegistration(this, - openedDatabase, - registerInfo) - } else { - showReadOnlySaveMessage() - } - }, - { - // If database not open - FileDatabaseSelectActivity.launchForRegistration(this, - registerInfo) + SearchHelper.checkAutoSearchInfo( + context = this, + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, _ -> + if (!readOnly) { + // Show the database UI to select the entry + GroupActivity.launchForRegistration( + context = this, + activityResultLauncher = null, // TODO Autofill result launcher #765 + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) + } else { + showReadOnlySaveMessage() } + }, + onItemNotFound = { openedDatabase -> + if (!readOnly) { + // Show the database UI to select the entry + GroupActivity.launchForRegistration( + context = this, + activityResultLauncher = null, // TODO Autofill result launcher #765 + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) + } else { + showReadOnlySaveMessage() + } + }, + onDatabaseClosed = { + // If database not open + FileDatabaseSelectActivity.launchForRegistration( + context = this, + activityResultLauncher = null, // TODO Autofill result launcher #765 + registerInfo = registerInfo, + typeMode = TypeMode.AUTOFILL + ) + } ) } finish() diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/CreatePasskeyActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/CreatePasskeyActivity.kt deleted file mode 100644 index 5087a5c3e..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/CreatePasskeyActivity.kt +++ /dev/null @@ -1,281 +0,0 @@ -package com.kunzisoft.keepass.credentialprovider.activity - -import android.app.Activity -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.util.Log -import androidx.annotation.RequiresApi -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt -import androidx.credentials.CreatePublicKeyCredentialRequest -import androidx.credentials.CreatePublicKeyCredentialResponse -import androidx.credentials.exceptions.CreateCredentialUnknownException -import androidx.credentials.exceptions.GetCredentialUnknownException -import androidx.credentials.provider.PendingIntentHandler -import com.kunzisoft.asymmetric.Signature -import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.activities.legacy.DatabaseActivity -import com.kunzisoft.keepass.credentialprovider.data.Passkey -import com.kunzisoft.keepass.credentialprovider.data.PublicKeyCredentialCreationOptions -import com.kunzisoft.keepass.credentialprovider.util.AppRelyingPartyRelation -import com.kunzisoft.keepass.credentialprovider.util.Base64Helper -import com.kunzisoft.keepass.credentialprovider.util.DatabaseHelper -import com.kunzisoft.keepass.credentialprovider.util.IntentHelper -import com.kunzisoft.keepass.credentialprovider.util.JsonHelper -import com.kunzisoft.keepass.credentialprovider.util.OriginHelper -import com.kunzisoft.keepass.database.ContextualDatabase -import com.kunzisoft.keepass.database.DatabaseTaskProvider -import com.kunzisoft.random.KeePassDXRandom - -@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) -class CreatePasskeyActivity : DatabaseActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - Log.d(javaClass.simpleName, "onCreate called") - super.onCreate(savedInstanceState) - } - - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - Log.d(javaClass.simpleName, "onDatabaseRetrieved called") - super.onDatabaseRetrieved(database) - - try { - if (database == null) { - throw CreateCredentialUnknownException("retrievedDatabase is null, maybe database is locked") - } - - if (intent == null) { - throw CreateCredentialUnknownException("intent is null") - } - - if (mDatabaseTaskProvider == null) { - throw CreateCredentialUnknownException("mDatabaseTaskProvider is null") - } - - createPasskeyAfterPrompt(database, mDatabaseTaskProvider!!, intent) - } catch (e: CreateCredentialUnknownException) { - Log.e(this::class.java.simpleName, "CreateCredentialUnknownException was thrown", e) - setResult(RESULT_CANCELED) - finish() - } catch (e: Exception) { - Log.e(this::class.java.simpleName, "other exception was thrown", e) - setResult(RESULT_CANCELED) - finish() - } - - } - - private fun createPasskeyAfterPrompt( - database: ContextualDatabase, - databaseTaskProvider: DatabaseTaskProvider, - intent: Intent - ) { - - val request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) - ?: throw CreateCredentialUnknownException("could not retrieve request from intent") - - if (request.callingRequest !is CreatePublicKeyCredentialRequest) { - throw CreateCredentialUnknownException("callingRequest is of wrong type: ${request.callingRequest.type}") - } - val publicKeyRequest = request.callingRequest as CreatePublicKeyCredentialRequest - - val creationOptions = JsonHelper.parseJsonToCreateOptions(publicKeyRequest.requestJson) - - val relyingParty = creationOptions.relyingParty - - val biometricPrompt = BiometricPrompt( - this, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError( - errorCode: Int, errString: CharSequence - ) { - super.onAuthenticationError(errorCode, errString) - throw CreateCredentialUnknownException("authentication error: errorCode = $errorCode, errString = $errString") - } - - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - throw CreateCredentialUnknownException("authentication failed") - } - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - createPasskey(database, databaseTaskProvider, intent, creationOptions) - } - } - ) - - val title = getString(R.string.passkey_creation_biometric_prompt_title) - val subtitle = - getString( - R.string.passkey_creation_biometric_prompt_subtitle, - relyingParty - ) - val negativeButtonText = - getString(R.string.passkey_creation_biometric_prompt_negative_button_text) - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(title) - .setSubtitle(subtitle) - .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) - .setNegativeButtonText(negativeButtonText) - .build() - biometricPrompt.authenticate(promptInfo) - } - - private fun createPasskey( - database: ContextualDatabase, - databaseTaskProvider: DatabaseTaskProvider, - intent: Intent, - creationOptions: PublicKeyCredentialCreationOptions - ) { - val request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) - - val nodeId = IntentHelper.getVerifiedNodeId(intent) - ?: throw CreateCredentialUnknownException("could not get verified nodeId from intent") - - val callingAppInfo = request!!.callingAppInfo - val relyingParty = creationOptions.relyingParty - val challenge = creationOptions.challenge - val keyTypeIdList = creationOptions.keyTypeIdList - val webOrigin = OriginHelper.getWebOrigin(callingAppInfo, assets) - val apkSigningCertificate = - callingAppInfo.signingInfo.apkContentsSigners.getOrNull(0)?.toByteArray() - - createPasskeyWithParameters( - relyingParty, - creationOptions.username, - creationOptions.userId, - database, - databaseTaskProvider, - keyTypeIdList, - challenge, - webOrigin, - apkSigningCertificate, - nodeId - ) - } - - private fun createPasskeyWithParameters( - relyingParty: String, - username: String, - userHandle: ByteArray, - database: ContextualDatabase, - databaseTaskProvider: DatabaseTaskProvider, - keyTypeIdList: List, - challenge: ByteArray, - webOrigin: String?, - apkSigningCertificate: ByteArray?, - nodeId: String - ) { - - val isPrivilegedApp = - (webOrigin != null && webOrigin == OriginHelper.DEFAULT_PROTOCOL + relyingParty) - Log.d(this::class.java.simpleName, "isPrivilegedApp = $isPrivilegedApp") - - if (!isPrivilegedApp) { - val isValid = - AppRelyingPartyRelation.isRelationValid(relyingParty, apkSigningCertificate) - if (!isValid) { - throw CreateCredentialUnknownException( - "could not verify relation between app " + - "and relyingParty $relyingParty" - ) - } - } - - val credentialId = KeePassDXRandom.generateCredentialId() - - val (keyPair, keyTypeId) = Signature.generateKeyPair(keyTypeIdList) - ?: throw CreateCredentialUnknownException("no known public key type found") - val privateKeyPem = Signature.convertPrivateKeyToPem(keyPair.private) - - if (IntentHelper.isPlaceholderNodeId(nodeId)) { - // create new entry in database - - val displayName = "$relyingParty (Passkey)" - val newPasskey = Passkey( - nodeId = "", // created by the database - username = username, - displayName = displayName, - privateKeyPem = privateKeyPem, - credId = Base64Helper.b64Encode(credentialId), - userHandle = Base64Helper.b64Encode(userHandle), - relyingParty = relyingParty, - databaseEntry = null - ) - - DatabaseHelper.saveNewEntry(database, databaseTaskProvider, newPasskey) - } else { - // update an existing entry in database - val oldPasskey = DatabaseHelper.searchPassKeyByNodeId(database, nodeId) - ?: throw GetCredentialUnknownException("no passkey with nodeId $nodeId found") - - val updatedPasskey = Passkey( - nodeId = "", // unchanged - username = username, - displayName = oldPasskey.displayName, - privateKeyPem = privateKeyPem, - credId = Base64Helper.b64Encode(credentialId), - userHandle = Base64Helper.b64Encode(userHandle), - relyingParty = relyingParty, - databaseEntry = oldPasskey.databaseEntry - ) - - DatabaseHelper.updateEntry(database, databaseTaskProvider, updatedPasskey) - } - - val publicKeyEncoded = Signature.convertPublicKey(keyPair.public, keyTypeId) - - val publicKeyMap = Signature.convertPublicKeyToMap(keyPair.public, keyTypeId) - val publicKeyCbor = JsonHelper.generateCborFromMap(publicKeyMap!!) - - val authData = JsonHelper.generateAuthDataForCreate( - userPresent = true, - userVerified = true, - backupEligibility = true, - backupState = true, - rpId = relyingParty.toByteArray(), - credentialId = credentialId, - credentialPublicKey = publicKeyCbor - ) - - val attestationObject = JsonHelper.generateAttestationObject(authData) - - val clientJson: String - if (isPrivilegedApp) { - clientJson = JsonHelper.generateClientDataJsonPrivileged() - } else { - val origin = OriginHelper.DEFAULT_PROTOCOL + relyingParty - clientJson = JsonHelper.generateClientDataJsonNonPrivileged( - challenge, - origin, - packageName, - isCrossOriginAdded = true, - isGet = false - ) - } - - val responseJson = JsonHelper.createAuthenticatorAttestationResponseJSON( - credentialId, - clientJson, - attestationObject, - publicKeyEncoded!!, - authData, - keyTypeId - ) - - // log only the length to prevent logging sensitive information - Log.d(javaClass.simpleName, "responseJson with length ${responseJson.length} created") - val createPublicKeyCredResponse = CreatePublicKeyCredentialResponse(responseJson) - - val resultOfActivity = Intent() - - PendingIntentHandler.setCreateCredentialResponse( - resultOfActivity, createPublicKeyCredResponse - ) - setResult(Activity.RESULT_OK, resultOfActivity) - finish() - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/EntrySelectionLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/EntrySelectionLauncherActivity.kt similarity index 59% rename from app/src/main/java/com/kunzisoft/keepass/activities/EntrySelectionLauncherActivity.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/EntrySelectionLauncherActivity.kt index 679ab39ba..2c1bdb4f5 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/EntrySelectionLauncherActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/EntrySelectionLauncherActivity.kt @@ -17,23 +17,25 @@ * along with KeePassDX. If not, see . * */ -package com.kunzisoft.keepass.activities +package com.kunzisoft.keepass.credentialprovider.activity import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle import android.widget.Toast +import androidx.core.net.toUri import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity +import com.kunzisoft.keepass.activities.GroupActivity import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity +import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.helper.SearchHelper -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.otp.OtpEntryFields import com.kunzisoft.keepass.utils.KeyboardUtil.isKeyboardActivatedInSettings -import com.kunzisoft.keepass.utils.getParcelableCompat import com.kunzisoft.keepass.utils.WebDomain +import com.kunzisoft.keepass.utils.getParcelableCompat /** * Activity to search or select entry in database, @@ -73,7 +75,7 @@ class EntrySelectionLauncherActivity : DatabaseModeActivity() { if (OtpEntryFields.isOTPUri(extra)) otpString = extra else - sharedWebDomain = Uri.parse(extra).host + sharedWebDomain = extra.toUri().host } } launchSelection(database, sharedWebDomain, otpString) @@ -121,87 +123,105 @@ class EntrySelectionLauncherActivity : DatabaseModeActivity() { // If database is open val readOnly = database?.isReadOnly != false - SearchHelper.checkAutoSearchInfo(this, - database, - searchInfo, - { openedDatabase, items -> - // Items found - if (searchInfo.otpString != null) { - if (!readOnly) { - GroupActivity.launchForSaveResult( + SearchHelper.checkAutoSearchInfo( + context = this, + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, items -> + // Items found + if (searchInfo.otpString != null) { + if (!readOnly) { + GroupActivity.launchForSaveResult( + this, + openedDatabase, + searchInfo, + false + ) + } else { + Toast.makeText(applicationContext, + R.string.autofill_read_only_save, + Toast.LENGTH_LONG) + .show() + } + } else if (searchShareForMagikeyboard) { + MagikeyboardService.performSelection( + items, + { entryInfo -> + // Automatically populate keyboard + MagikeyboardService.populateKeyboardAndMoveAppToBackground( + this, + entryInfo + ) + }, + { autoSearch -> + GroupActivity.launchForKeyboardSelectionResult( this, openedDatabase, searchInfo, - false) - } else { - Toast.makeText(applicationContext, - R.string.autofill_read_only_save, - Toast.LENGTH_LONG) - .show() + autoSearch + ) } - } else if (searchShareForMagikeyboard) { - MagikeyboardService.performSelection( - items, - { entryInfo -> - // Automatically populate keyboard - MagikeyboardService.populateKeyboardAndMoveAppToBackground( - this, - entryInfo - ) - }, - { autoSearch -> - GroupActivity.launchForKeyboardSelectionResult(this, - openedDatabase, - searchInfo, - autoSearch) - } + ) + } else { + GroupActivity.launchForSearchResult( + this, + openedDatabase, + searchInfo, + true + ) + } + }, + onItemNotFound = { openedDatabase -> + // Show the database UI to select the entry + if (searchInfo.otpString != null) { + if (!readOnly) { + GroupActivity.launchForSaveResult( + this, + openedDatabase, + searchInfo, + false ) } else { - GroupActivity.launchForSearchResult(this, - openedDatabase, - searchInfo, - true) - } - }, - { openedDatabase -> - // Show the database UI to select the entry - if (searchInfo.otpString != null) { - if (!readOnly) { - GroupActivity.launchForSaveResult(this, - openedDatabase, - searchInfo, - false) - } else { - Toast.makeText(applicationContext, - R.string.autofill_read_only_save, - Toast.LENGTH_LONG) - .show() - } - } else if (searchShareForMagikeyboard) { - GroupActivity.launchForKeyboardSelectionResult(this, - openedDatabase, - searchInfo, - false) - } else { - GroupActivity.launchForSearchResult(this, - openedDatabase, - searchInfo, - false) - } - }, - { - // If database not open - if (searchInfo.otpString != null) { - FileDatabaseSelectActivity.launchForSaveResult(this, - searchInfo) - } else if (searchShareForMagikeyboard) { - FileDatabaseSelectActivity.launchForKeyboardSelectionResult(this, - searchInfo) - } else { - FileDatabaseSelectActivity.launchForSearchResult(this, - searchInfo) + Toast.makeText(applicationContext, + R.string.autofill_read_only_save, + Toast.LENGTH_LONG) + .show() } + } else if (searchShareForMagikeyboard) { + GroupActivity.launchForKeyboardSelectionResult( + this, + openedDatabase, + searchInfo, + false + ) + } else { + GroupActivity.launchForSearchResult( + this, + openedDatabase, + searchInfo, + false + ) } + }, + onDatabaseClosed = { + // If database not open + if (searchInfo.otpString != null) { + FileDatabaseSelectActivity.launchForSaveResult( + this, + searchInfo + ) + } else if (searchShareForMagikeyboard) { + FileDatabaseSelectActivity.launchForKeyboardSelectionResult( + this, + searchInfo + ) + } else { + FileDatabaseSelectActivity.launchForSearchResult( + this, + searchInfo + ) + } + } ) } diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt new file mode 100644 index 000000000..de433806d --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt @@ -0,0 +1,348 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ +package com.kunzisoft.keepass.credentialprovider.activity + +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.RequiresApi +import androidx.credentials.GetCredentialResponse +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.PendingIntentHandler +import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity +import com.kunzisoft.keepass.activities.GroupActivity +import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addSpecialMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.TypeMode +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters +import com.kunzisoft.keepass.credentialprovider.passkey.util.OriginHelper +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addAuthCode +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildCreatePublicKeyCredentialResponse +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyPublicKeyCredential +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removePasskey +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveNodeId +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskey +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyCreationComponent +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyCreationRequestParameters +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyUsageRequestParameters +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.database.element.node.NodeIdUUID +import com.kunzisoft.keepass.database.helper.SearchHelper +import com.kunzisoft.keepass.model.EntryInfoPasskey.getPasskey +import com.kunzisoft.keepass.model.Passkey +import com.kunzisoft.keepass.model.RegisterInfo +import com.kunzisoft.keepass.model.SearchInfo +import com.kunzisoft.keepass.utils.getParcelableExtraCompat +import java.util.UUID + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class PasskeyLauncherActivity : DatabaseModeActivity() { + + private var mUsageParameters: PublicKeyCredentialUsageParameters? = null + private var mCreationParameters: PublicKeyCredentialCreationParameters? = null + private var mPasskey: Passkey? = null + + private var mPasskeySelectionActivityResultLauncher: ActivityResultLauncher? = + this.buildActivityResultLauncher( + lockDatabase = true, + dataTransformation = { intent -> + Log.d(TAG, "Passkey selection result") + val passkey = intent?.retrievePasskey() + intent?.removePasskey() + // Build a new formatted response from the selection response + val responseIntent = Intent() + passkey?.let { + mUsageParameters?.let { usageParameters -> + PendingIntentHandler.setGetCredentialResponse( + responseIntent, + GetCredentialResponse( + buildPasskeyPublicKeyCredential( + usageParameters = usageParameters, + passkey = passkey + ) + ) + ) + } ?: run { + Log.e(TAG, "Unable to return passkey, usage parameters are empty") + } + } ?: run { + Log.e(TAG, "Unable to get the passkey for response") + } + // Return the response + responseIntent + } + ) + + private var mPasskeyRegistrationActivityResultLauncher: ActivityResultLauncher? = + this.buildActivityResultLauncher( + lockDatabase = true, + dataTransformation = { intent -> + Log.d(TAG, "Passkey registration result") + val passkey = intent?.retrievePasskey() + intent?.removePasskey() + // Build a new formatted response from the creation response + val responseIntent = Intent() + // If registered passkey is the same as the one we want to validate, + if (mPasskey == passkey) { + mCreationParameters?.let { + PendingIntentHandler.setCreateCredentialResponse( + intent = responseIntent, + response = buildCreatePublicKeyCredentialResponse( + packageName = packageName, + publicKeyCredentialCreationParameters = it + ) + ) + } + } + responseIntent + } + ) + + override fun applyCustomStyle(): Boolean { + return false + } + + override fun finishActivityIfReloadRequested(): Boolean { + return false + } + + override fun onDatabaseRetrieved(database: ContextualDatabase?) { + super.onDatabaseRetrieved(database) + + // TODO nodeId Really useful ? checkSecurity(intent, nodeId) + EntrySelectionHelper.retrieveSpecialModeFromIntent(intent).let { specialMode -> + val searchInfo = intent.retrieveSearchInfo() ?: SearchInfo() + when (specialMode) { + SpecialMode.SELECTION -> { + launchSelection(database, searchInfo) + } + SpecialMode.REGISTRATION -> { + launchRegistration(database, searchInfo) + } + else -> { + Log.e(TAG, "Passkey launch mode not supported") + setResult(Activity.RESULT_CANCELED) + finish() + } + } + } + } + + private fun autoSelectPasskeyAndSetResult( + database: ContextualDatabase?, + nodeId: UUID + ) { + mUsageParameters?.let { usageParameters -> + // To get the passkey from the database + val passkey = database + ?.getEntryById(NodeIdUUID(nodeId)) + ?.getEntryInfo(database) + ?.getPasskey() + ?: throw GetCredentialUnknownException("no passkey with nodeId $nodeId found") + + val result = Intent() + PendingIntentHandler.setGetCredentialResponse( + result, + GetCredentialResponse( + buildPasskeyPublicKeyCredential( + usageParameters = usageParameters, + passkey = passkey + ) + ) + ) + setResult(RESULT_OK, result) + finish() + } ?: run { + Log.e(TAG, "Unable to auto select passkey, usage parameters are empty") + setResult(Activity.RESULT_CANCELED) + finish() + } + } + + private fun launchSelection( + database: ContextualDatabase?, + searchInfo: SearchInfo? + ) { + Log.d(TAG, "Launch passkey selection") + retrievePasskeyUsageRequestParameters(this@PasskeyLauncherActivity, intent) { usageParameters -> + // Save the requested parameters + mUsageParameters = usageParameters + // Manage the passkey to use + intent.retrieveNodeId()?.let { nodeId -> + autoSelectPasskeyAndSetResult(database, nodeId) + } ?: run { + SearchHelper.checkAutoSearchInfo( + context = this, + database = database, + searchInfo = searchInfo, + onItemsFound = { _, _ -> + Log.w(TAG, "Passkey found for auto selection, should not append," + + "use PasskeyProviderService instead") + finish() + }, + onItemNotFound = { openedDatabase -> + Log.d(TAG, "No Passkey found for selection," + + "launch manual selection in opened database") + GroupActivity.launchForPasskeySelectionResult( + context = this, + database = openedDatabase, + activityResultLauncher = mPasskeySelectionActivityResultLauncher, + searchInfo = null, + autoSearch = true + ) + }, + onDatabaseClosed = { + Log.d(TAG, "Manual passkey selection in closed database") + FileDatabaseSelectActivity.launchForPasskeySelectionResult( + activity = this, + activityResultLauncher = mPasskeySelectionActivityResultLauncher, + searchInfo = searchInfo, + ) + } + ) + } + } + } + + private fun autoRegisterPasskeyAndSetResult( + database: ContextualDatabase?, + nodeId: UUID + ) { + // TODO Overwrite automatic selection + mCreationParameters?.let { creationParameters -> + // To set the passkey to the database + setResult(RESULT_OK) + finish() + } ?: run { + Log.e(TAG, "Unable to auto select passkey, usage parameters are empty") + setResult(Activity.RESULT_CANCELED) + finish() + } + } + + private fun launchRegistration( + database: ContextualDatabase?, + searchInfo: SearchInfo + ) { + Log.d(TAG, "Launch passkey registration") + PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)?.callingAppInfo?.let { callingAppInfo -> + retrievePasskeyCreationRequestParameters( + creationOptions = intent.retrievePasskeyCreationComponent(), + webOrigin = OriginHelper.getWebOrigin(callingAppInfo, assets), + apkSigningCertificate = + callingAppInfo + .signingInfo.apkContentsSigners + .getOrNull(0)?.toByteArray(), + passkeyCreated = { passkey, publicKeyCredentialParameters -> + // Save the requested parameters + mPasskey = passkey + mCreationParameters = publicKeyCredentialParameters + // Manage the passkey and create a register info + val registerInfo = RegisterInfo( + searchInfo = searchInfo, + username = null, + passkey = passkey + ) + // If nodeId already provided + intent.retrieveNodeId()?.let { nodeId -> + autoRegisterPasskeyAndSetResult(database, nodeId) + } ?: run { + SearchHelper.checkAutoSearchInfo( + context = this, + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, _ -> + Log.w(TAG, "Passkey found for registration, " + + "but launch manual registration for overwrite") + GroupActivity.launchForRegistration( + context = this, + activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + }, + onItemNotFound = { openedDatabase -> + Log.d(TAG, "Launch new manual registration in opened database") + GroupActivity.launchForRegistration( + context = this, + activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + }, + onDatabaseClosed = { + Log.d(TAG, "Manual passkey registration in closed database") + FileDatabaseSelectActivity.launchForRegistration( + context = this, + activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + } + ) + } + } + ) + } + } + + companion object { + private val TAG = PasskeyLauncherActivity::class.java.name + private const val EXTRA_SEARCH_INFO = "com.kunzisoft.keepass.extra.SEARCH_INFO" + + fun Intent.retrieveSearchInfo(): SearchInfo? { + return this.getParcelableExtraCompat(EXTRA_SEARCH_INFO) + } + + fun Intent.removeSearchInfo() { + return this.removeExtra(EXTRA_SEARCH_INFO) + } + + fun getPendingIntent( + context: Context, + specialMode: SpecialMode, + searchInfo: SearchInfo? = null, + passkeyEntryNodeId: UUID? = null + ): PendingIntent? { + return PendingIntent.getActivity( + context, + Math.random().toInt(), + Intent(context, PasskeyLauncherActivity::class.java).apply { + addSpecialMode(specialMode) + searchInfo?.let { + putExtra(EXTRA_SEARCH_INFO, searchInfo) + } + addAuthCode(passkeyEntryNodeId) + }, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT + ) + } + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/UsePasskeyActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/UsePasskeyActivity.kt deleted file mode 100644 index 5f915c77e..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/UsePasskeyActivity.kt +++ /dev/null @@ -1,236 +0,0 @@ -package com.kunzisoft.keepass.credentialprovider.activity - -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.util.Log -import androidx.annotation.RequiresApi -import androidx.biometric.BiometricManager -import androidx.biometric.BiometricPrompt -import androidx.credentials.GetCredentialResponse -import androidx.credentials.GetPublicKeyCredentialOption -import androidx.credentials.PublicKeyCredential -import androidx.credentials.exceptions.CreateCredentialUnknownException -import androidx.credentials.exceptions.GetCredentialUnknownException -import androidx.credentials.provider.PendingIntentHandler -import com.kunzisoft.asymmetric.Signature -import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.activities.legacy.DatabaseActivity -import com.kunzisoft.keepass.credentialprovider.data.Passkey -import com.kunzisoft.keepass.credentialprovider.util.AppRelyingPartyRelation -import com.kunzisoft.keepass.credentialprovider.util.Base64Helper -import com.kunzisoft.keepass.credentialprovider.util.DatabaseHelper -import com.kunzisoft.keepass.credentialprovider.util.IntentHelper -import com.kunzisoft.keepass.credentialprovider.util.JsonHelper -import com.kunzisoft.keepass.credentialprovider.util.OriginHelper -import com.kunzisoft.keepass.credentialprovider.util.OriginHelper.Companion.DEFAULT_PROTOCOL -import com.kunzisoft.keepass.database.ContextualDatabase - - -@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) -class UsePasskeyActivity : DatabaseActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - Log.d(javaClass.simpleName, "onCreate called") - super.onCreate(savedInstanceState) - } - - - override fun onDatabaseRetrieved(database: ContextualDatabase?) { - Log.d(javaClass.simpleName, "onDatabaseRetrieved called") - super.onDatabaseRetrieved(database) - - try { - if (database == null) { - throw CreateCredentialUnknownException("retrievedDatabase is null, maybe database is locked") - } - - if (intent == null) { - throw CreateCredentialUnknownException("intent is null") - } - usePasskeyAfterPrompt(database, intent) - } catch (e: CreateCredentialUnknownException) { - Log.e(this::class.java.simpleName, "CreateCredentialUnknownException was thrown", e) - setResult(RESULT_CANCELED) - finish() - } catch (e: Exception) { - Log.e(this::class.java.simpleName, "other exception was thrown", e) - setResult(RESULT_CANCELED) - finish() - } - } - - private fun usePasskeyAfterPrompt( - database: ContextualDatabase, - intent: Intent - ) { - val request = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) - ?: throw CreateCredentialUnknownException("could not retrieve request from intent") - - if (request.credentialOptions.size != 1) { - throw GetCredentialUnknownException("not exact one credentialOption") - } - - if (request.credentialOptions[0] !is GetPublicKeyCredentialOption) { - throw CreateCredentialUnknownException("credentialOptions is of wrong type: ${request.credentialOptions[0]}") - } - - val credentialOption = request.credentialOptions[0] as GetPublicKeyCredentialOption - val clientDataHash = credentialOption.clientDataHash - - val requestOptions = JsonHelper.parseJsonToRequestOptions(credentialOption.requestJson) - - val relyingParty = requestOptions.relyingParty - val challenge = Base64Helper.b64Decode(requestOptions.challengeString) - val packageName = request.callingAppInfo.packageName - val webOrigin = OriginHelper.getWebOrigin(request.callingAppInfo, assets) - - val isPrivilegedApp = - (webOrigin != null && webOrigin == DEFAULT_PROTOCOL + relyingParty && clientDataHash != null) - - Log.d(javaClass.simpleName, "isPrivilegedApp = $isPrivilegedApp") - - if (!isPrivilegedApp) { - val apkSigners = request.callingAppInfo.signingInfo.apkContentsSigners - val apkSigningCertificate = apkSigners.getOrNull(0)?.toByteArray() - val isValid = - AppRelyingPartyRelation.isRelationValid(relyingParty, apkSigningCertificate) - if (!isValid) { - throw CreateCredentialUnknownException( - "could not verify relation between app " + - "and relyingParty $relyingParty" - ) - } - } - - val nodeId = IntentHelper.getVerifiedNodeId(intent) - ?: throw GetCredentialUnknownException("could not get verified nodeId from intent") - - val passkey = DatabaseHelper.searchPassKeyByNodeId(database, nodeId) - ?: throw GetCredentialUnknownException("no passkey with nodeId $nodeId found") - - usePasskeyAfterPromptWithParameters( - relyingParty, - packageName, - clientDataHash, - isPrivilegedApp, - challenge, - passkey - ) - } - - private fun usePasskeyAfterPromptWithParameters( - relyingParty: String, - packageName: String, - clientDataHash: ByteArray?, - isPrivilegedApp: Boolean, - challenge: ByteArray, - passkey: Passkey - ) { - val biometricPrompt = BiometricPrompt( - this, - object : BiometricPrompt.AuthenticationCallback() { - override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - super.onAuthenticationError(errorCode, errString) - throw GetCredentialUnknownException("authentication error: errorCode = $errorCode, errString = $errString") - } - - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - throw GetCredentialUnknownException("authentication failed") - } - - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { - super.onAuthenticationSucceeded(result) - createResponse( - relyingParty, - packageName, - clientDataHash, - isPrivilegedApp, - challenge, - passkey - ) - } - } - ) - - val title = getString(R.string.passkey_usage_biometric_prompt_title) - val subtitle = getString(R.string.passkey_usage_biometric_prompt_subtitle, relyingParty) - val negativeButtonText = - getString(R.string.passkey_usage_biometric_prompt_negative_button_text) - val promptInfo = BiometricPrompt.PromptInfo.Builder() - .setTitle(title) - .setSubtitle(subtitle) - .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) - .setNegativeButtonText(negativeButtonText) - .build() - biometricPrompt.authenticate(promptInfo) - - - } - - private fun createResponse( - relyingParty: String, - packageName: String, - clientDataHash: ByteArray?, - isPrivilegedApp: Boolean, - challenge: ByteArray, - passkey: Passkey - ) { - - // https://www.w3.org/TR/webauthn-3/#authdata-flags - val userPresent = true - val userVerified = true - val backupEligibility = true - val backupState = true - - val authenticatorData = JsonHelper.generateAuthDataForUsage( - relyingParty.toByteArray(), - userPresent, - userVerified, - backupEligibility, - backupState - ) - - val clientDataJson: String - val dataToSign: ByteArray - if (isPrivilegedApp) { - clientDataJson = JsonHelper.generateClientDataJsonPrivileged() - dataToSign = - JsonHelper.generateDataToSignPrivileged(clientDataHash!!, authenticatorData) - } else { - val origin = DEFAULT_PROTOCOL + relyingParty - clientDataJson = JsonHelper.generateClientDataJsonNonPrivileged( - challenge, - origin, - packageName, - isGet = true, - isCrossOriginAdded = false - ) - dataToSign = - JsonHelper.generateDataTosSignNonPrivileged(clientDataJson, authenticatorData) - } - - val signature = Signature.sign(passkey.privateKeyPem, dataToSign) - ?: throw GetCredentialUnknownException("signing failed") - - val getCredentialResponse = - JsonHelper.generateGetCredentialResponse( - clientDataJson.toByteArray(), - authenticatorData, - signature, - passkey.userHandle, - passkey.credId - ) - - val result = Intent() - val passkeyCredential = PublicKeyCredential(getCredentialResponse) - PendingIntentHandler.setGetCredentialResponse( - result, GetCredentialResponse(passkeyCredential) - ) - setResult(RESULT_OK, result) - finish() - } - - -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillComponent.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillComponent.kt similarity index 78% rename from app/src/main/java/com/kunzisoft/keepass/autofill/AutofillComponent.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillComponent.kt index bf8a2996f..3a4097cee 100644 --- a/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillComponent.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillComponent.kt @@ -1,4 +1,4 @@ -package com.kunzisoft.keepass.autofill +package com.kunzisoft.keepass.credentialprovider.autofill import android.app.assist.AssistStructure diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillHelper.kt similarity index 88% rename from app/src/main/java/com/kunzisoft/keepass/autofill/AutofillHelper.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillHelper.kt index b0af5bf53..2a7cab8f2 100644 --- a/app/src/main/java/com/kunzisoft/keepass/autofill/AutofillHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/AutofillHelper.kt @@ -17,7 +17,7 @@ * along with KeePassDX. If not, see . * */ -package com.kunzisoft.keepass.autofill +package com.kunzisoft.keepass.credentialprovider.autofill import android.annotation.SuppressLint import android.app.Activity @@ -40,17 +40,13 @@ import android.view.autofill.AutofillValue import android.widget.RemoteViews import android.widget.Toast import android.widget.inline.InlinePresentationSpec -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi -import androidx.appcompat.app.AppCompatActivity import androidx.autofill.inline.UiVersions import androidx.autofill.inline.v1.InlineSuggestionUi import androidx.core.content.ContextCompat import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.activities.AutofillLauncherActivity -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper -import com.kunzisoft.keepass.activities.helpers.SpecialMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildIcon +import com.kunzisoft.keepass.credentialprovider.activity.AutofillLauncherActivity import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.element.icon.IconImage import com.kunzisoft.keepass.database.element.template.TemplateField @@ -58,7 +54,6 @@ import com.kunzisoft.keepass.model.EntryInfo import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.settings.AutofillSettingsActivity import com.kunzisoft.keepass.settings.PreferencesUtil -import com.kunzisoft.keepass.utils.LOCK_ACTION import com.kunzisoft.keepass.utils.getParcelableExtraCompat import kotlin.math.min @@ -294,23 +289,6 @@ object AutofillHelper { return dataset } - /** - * Method to assign a drawable to a new icon from a database icon - */ - private fun buildIconFromEntry(context: Context, - database: ContextualDatabase, - entryInfo: EntryInfo): Icon? { - try { - database.iconDrawableFactory.getBitmapFromIcon(context, - entryInfo.icon, ContextCompat.getColor(context, R.color.green))?.let { bitmap -> - return Icon.createWithBitmap(bitmap) - } - } catch (e: Exception) { - Log.e(RemoteViews::class.java.name, "Unable to assign icon in remote view", e) - } - return null - } - @SuppressLint("RestrictedApi") @RequiresApi(Build.VERSION_CODES.R) private fun buildInlinePresentationForEntry(context: Context, @@ -353,7 +331,7 @@ object AutofillHelper { Icon.createWithResource(context, R.mipmap.ic_launcher_round).apply { setTintBlendMode(BlendMode.DST) }) - buildIconFromEntry(context, database, entryInfo)?.let { icon -> + entryInfo.buildIcon(context, database)?.let { icon -> setEndIcon(icon.apply { setTintBlendMode(BlendMode.DST) }) @@ -534,7 +512,9 @@ object AutofillHelper { StructureParser(structure).parse()?.let { result -> // New Response val response = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val compatInlineSuggestionsRequest = activity.intent?.getParcelableExtraCompat(EXTRA_INLINE_SUGGESTIONS_REQUEST) + val compatInlineSuggestionsRequest = activity.intent?.getParcelableExtraCompat( + EXTRA_INLINE_SUGGESTIONS_REQUEST + ) if (compatInlineSuggestionsRequest != null) { Toast.makeText(activity.applicationContext, R.string.autofill_inline_suggestions_keyboard, Toast.LENGTH_SHORT).show() } @@ -558,45 +538,14 @@ object AutofillHelper { } } - fun buildActivityResultLauncher(activity: AppCompatActivity, - lockDatabase: Boolean = false): ActivityResultLauncher { - return activity.registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - // Utility method to loop and close each activity with return data - if (it.resultCode == Activity.RESULT_OK) { - activity.setResult(it.resultCode, it.data) - } - if (it.resultCode == Activity.RESULT_CANCELED) { - activity.setResult(Activity.RESULT_CANCELED) - } - activity.finish() - - if (lockDatabase && PreferencesUtil.isAutofillCloseDatabaseEnable(activity)) { - // Close the database - activity.sendBroadcast(Intent(LOCK_ACTION)) - } - } - } - - /** - * Utility method to start an activity with an Autofill for result - */ - fun startActivityForAutofillResult(activity: AppCompatActivity, - intent: Intent, - activityResultLauncher: ActivityResultLauncher?, - autofillComponent: AutofillComponent, - searchInfo: SearchInfo?) { - EntrySelectionHelper.addSpecialModeInIntent(intent, SpecialMode.SELECTION) - intent.putExtra(EXTRA_ASSIST_STRUCTURE, autofillComponent.assistStructure) + fun Intent.addAutofillComponent(context: Context, autofillComponent: AutofillComponent) { + this.putExtra(EXTRA_ASSIST_STRUCTURE, autofillComponent.assistStructure) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R - && PreferencesUtil.isAutofillInlineSuggestionsEnable(activity)) { + && PreferencesUtil.isAutofillInlineSuggestionsEnable(context)) { autofillComponent.compatInlineSuggestionsRequest?.let { - intent.putExtra(EXTRA_INLINE_SUGGESTIONS_REQUEST, it) + this.putExtra(EXTRA_INLINE_SUGGESTIONS_REQUEST, it) } } - EntrySelectionHelper.addSearchInfoInIntent(intent, searchInfo) - activityResultLauncher?.launch(intent) } private val TAG = AutofillHelper::class.java.name diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/CompatInlineSuggestionsRequest.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/CompatInlineSuggestionsRequest.kt similarity index 97% rename from app/src/main/java/com/kunzisoft/keepass/autofill/CompatInlineSuggestionsRequest.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/CompatInlineSuggestionsRequest.kt index 5705682b5..8fba6845b 100644 --- a/app/src/main/java/com/kunzisoft/keepass/autofill/CompatInlineSuggestionsRequest.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/CompatInlineSuggestionsRequest.kt @@ -17,7 +17,7 @@ * along with KeePassDX. If not, see . * */ -package com.kunzisoft.keepass.autofill +package com.kunzisoft.keepass.credentialprovider.autofill import android.annotation.TargetApi import android.os.Build diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/KeeAutofillService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/KeeAutofillService.kt similarity index 92% rename from app/src/main/java/com/kunzisoft/keepass/autofill/KeeAutofillService.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/KeeAutofillService.kt index 98979a9d9..9d832e3e1 100644 --- a/app/src/main/java/com/kunzisoft/keepass/autofill/KeeAutofillService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/KeeAutofillService.kt @@ -17,7 +17,7 @@ * along with KeePassDX. If not, see . * */ -package com.kunzisoft.keepass.autofill +package com.kunzisoft.keepass.credentialprovider.autofill import android.annotation.SuppressLint import android.app.PendingIntent @@ -43,8 +43,8 @@ import androidx.annotation.RequiresApi import androidx.autofill.inline.UiVersions import androidx.autofill.inline.v1.InlineSuggestionUi import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.activities.AutofillLauncherActivity -import com.kunzisoft.keepass.autofill.StructureParser.Companion.APPLICATION_ID_POPUP_WINDOW +import com.kunzisoft.keepass.credentialprovider.activity.AutofillLauncherActivity +import com.kunzisoft.keepass.credentialprovider.autofill.StructureParser.Companion.APPLICATION_ID_POPUP_WINDOW import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.DatabaseTaskProvider import com.kunzisoft.keepass.database.helper.SearchHelper @@ -143,25 +143,28 @@ class KeeAutofillService : AutofillService() { parseResult: StructureParser.Result, inlineSuggestionsRequest: CompatInlineSuggestionsRequest?, callback: FillCallback) { - SearchHelper.checkAutoSearchInfo(this, - database, - searchInfo, - { openedDatabase, items -> - callback.onSuccess( - AutofillHelper.buildResponse(this, openedDatabase, - items, parseResult, inlineSuggestionsRequest) + SearchHelper.checkAutoSearchInfo( + context = this, + database = database, + searchInfo = searchInfo, + onItemsFound = { openedDatabase, items -> + callback.onSuccess( + AutofillHelper.buildResponse( + this, openedDatabase, + items, parseResult, inlineSuggestionsRequest ) - }, - { openedDatabase -> - // Show UI if no search result - showUIForEntrySelection(parseResult, openedDatabase, - searchInfo, inlineSuggestionsRequest, callback) - }, - { - // Show UI if database not open - showUIForEntrySelection(parseResult, null, - searchInfo, inlineSuggestionsRequest, callback) - } + ) + }, + onItemNotFound = { openedDatabase -> + // Show UI if no search result + showUIForEntrySelection(parseResult, openedDatabase, + searchInfo, inlineSuggestionsRequest, callback) + }, + onDatabaseClosed = { + // Show UI if database not open + showUIForEntrySelection(parseResult, null, + searchInfo, inlineSuggestionsRequest, callback) + } ) } @@ -385,19 +388,21 @@ class KeeAutofillService : AutofillService() { // Show UI to save data val registerInfo = RegisterInfo( - SearchInfo().apply { - applicationId = parseResult.applicationId - webDomain = parseResult.webDomain - webScheme = parseResult.webScheme - }, - parseResult.usernameValue?.textValue?.toString(), - parseResult.passwordValue?.textValue?.toString(), + searchInfo = SearchInfo().apply { + applicationId = parseResult.applicationId + webDomain = parseResult.webDomain + webScheme = parseResult.webScheme + }, + username = parseResult.usernameValue?.textValue?.toString(), + password = parseResult.passwordValue?.textValue?.toString(), + creditCard = CreditCard( parseResult.creditCardHolder, parseResult.creditCardNumber, expiration, parseResult.cardVerificationValue - )) + ) + ) // TODO Callback in each activity #765 //if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { diff --git a/app/src/main/java/com/kunzisoft/keepass/autofill/StructureParser.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/StructureParser.kt similarity index 99% rename from app/src/main/java/com/kunzisoft/keepass/autofill/StructureParser.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/StructureParser.kt index c4b7ad0c8..cfbda6df3 100644 --- a/app/src/main/java/com/kunzisoft/keepass/autofill/StructureParser.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/autofill/StructureParser.kt @@ -16,7 +16,7 @@ * You should have received a copy of the GNU General Public License * along with KeePassDX. If not, see . */ -package com.kunzisoft.keepass.autofill +package com.kunzisoft.keepass.credentialprovider.autofill import android.app.assist.AssistStructure import android.os.Build diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/Passkey.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/Passkey.kt deleted file mode 100644 index dfaadd0f0..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/Passkey.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.kunzisoft.keepass.credentialprovider.data - -import com.kunzisoft.keepass.database.element.Entry - -data class Passkey( - val nodeId: String, - val username: String, - val displayName: String, - val privateKeyPem: String, - val credId: String, - val userHandle: String, - val relyingParty: String, - val databaseEntry: Entry? -) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialCreationOptions.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialCreationOptions.kt deleted file mode 100644 index 187f1a054..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialCreationOptions.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.kunzisoft.keepass.credentialprovider.data - -data class PublicKeyCredentialCreationOptions( - val relyingParty: String, - val challenge: ByteArray, - val username: String, - val userId: ByteArray, - val keyTypeIdList: List -) \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/magikeyboard/Keyboard.java b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/Keyboard.java similarity index 99% rename from app/src/main/java/com/kunzisoft/keepass/magikeyboard/Keyboard.java rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/Keyboard.java index 313fa465f..b385a897c 100644 --- a/app/src/main/java/com/kunzisoft/keepass/magikeyboard/Keyboard.java +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/Keyboard.java @@ -14,7 +14,7 @@ * the License. */ -package com.kunzisoft.keepass.magikeyboard; +package com.kunzisoft.keepass.credentialprovider.magikeyboard; import android.content.Context; import android.content.res.Resources; diff --git a/app/src/main/java/com/kunzisoft/keepass/magikeyboard/KeyboardView.java b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/KeyboardView.java similarity index 98% rename from app/src/main/java/com/kunzisoft/keepass/magikeyboard/KeyboardView.java rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/KeyboardView.java index 1ee04d2ea..fc992247e 100644 --- a/app/src/main/java/com/kunzisoft/keepass/magikeyboard/KeyboardView.java +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/KeyboardView.java @@ -14,14 +14,14 @@ * the License. */ -package com.kunzisoft.keepass.magikeyboard; +package com.kunzisoft.keepass.credentialprovider.magikeyboard; -import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_BACK_KEYBOARD; -import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_CHANGE_KEYBOARD; -import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_ENTRY; -import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_ENTRY_ALT; -import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_OTP; -import static com.kunzisoft.keepass.magikeyboard.MagikeyboardService.KEY_OTP_ALT; +import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_BACK_KEYBOARD; +import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_CHANGE_KEYBOARD; +import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_ENTRY; +import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_ENTRY_ALT; +import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_OTP; +import static com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService.KEY_OTP_ALT; import android.content.Context; import android.content.res.TypedArray; @@ -52,7 +52,7 @@ import android.widget.TextView; import androidx.annotation.RequiresApi; import com.kunzisoft.keepass.R; -import com.kunzisoft.keepass.magikeyboard.Keyboard.Key; +import com.kunzisoft.keepass.credentialprovider.magikeyboard.Keyboard.Key; import java.util.Arrays; import java.util.HashMap; diff --git a/app/src/main/java/com/kunzisoft/keepass/magikeyboard/MagikeyboardService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/MagikeyboardService.kt similarity index 94% rename from app/src/main/java/com/kunzisoft/keepass/magikeyboard/MagikeyboardService.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/MagikeyboardService.kt index 34770e605..bc6f3073b 100644 --- a/app/src/main/java/com/kunzisoft/keepass/magikeyboard/MagikeyboardService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/magikeyboard/MagikeyboardService.kt @@ -18,7 +18,7 @@ * */ -package com.kunzisoft.keepass.magikeyboard +package com.kunzisoft.keepass.credentialprovider.magikeyboard import android.app.Activity import android.content.Context @@ -41,8 +41,8 @@ import androidx.core.graphics.BlendModeCompat import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.activities.EntrySelectionLauncherActivity -import com.kunzisoft.keepass.activities.helpers.EntrySelectionHelper +import com.kunzisoft.keepass.credentialprovider.activity.EntrySelectionLauncherActivity +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper import com.kunzisoft.keepass.adapters.FieldsAdapter import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.DatabaseTaskProvider @@ -341,10 +341,11 @@ class MagikeyboardService : InputMethodService(), KeyboardView.OnKeyboardActionL } private fun actionKeyEntry(searchInfo: SearchInfo? = null) { - SearchHelper.checkAutoSearchInfo(this, - mDatabase, - searchInfo, - { _, items -> + SearchHelper.checkAutoSearchInfo( + context = this, + database = mDatabase, + searchInfo = searchInfo, + onItemsFound = { _, items -> performSelection( items, { @@ -361,11 +362,11 @@ class MagikeyboardService : InputMethodService(), KeyboardView.OnKeyboardActionL } ) }, - { + onItemNotFound = { // Select if not found launchEntrySelection(searchInfo) }, - { + onDatabaseClosed = { // Select if database not opened removeEntryInfo() launchEntrySelection(searchInfo) @@ -463,21 +464,18 @@ class MagikeyboardService : InputMethodService(), KeyboardView.OnKeyboardActionL fun performSelection(items: List, actionPopulateKeyboard: (entryInfo: EntryInfo) -> Unit, actionEntrySelection: (autoSearch: Boolean) -> Unit) { - if (items.size == 1) { - val itemFound = items[0] - if (entryUUID != itemFound.id) { - actionPopulateKeyboard.invoke(itemFound) - } else { - // Force selection if magikeyboard already populated - actionEntrySelection.invoke(false) - } - } else if (items.size > 1) { - // Select the one we want in the selection - actionEntrySelection.invoke(true) - } else { - // Select an arbitrary one - actionEntrySelection.invoke(false) - } + EntrySelectionHelper.performSelection( + items = items, + actionPopulateCredentialProvider = { itemFound -> + if (entryUUID != itemFound.id) { + actionPopulateKeyboard.invoke(itemFound) + } else { + // Force selection if magikeyboard already populated + actionEntrySelection.invoke(false) + } + }, + actionEntrySelection = actionEntrySelection + ) } fun populateKeyboardAndMoveAppToBackground(activity: Activity, diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt new file mode 100644 index 000000000..f9740e9dd --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt @@ -0,0 +1,320 @@ +package com.kunzisoft.keepass.credentialprovider.passkey + +import android.graphics.BlendMode +import android.graphics.drawable.Icon +import android.os.Build +import android.os.CancellationSignal +import android.os.OutcomeReceiver +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.credentials.exceptions.ClearCredentialException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.BeginCreateCredentialRequest +import androidx.credentials.provider.BeginCreateCredentialResponse +import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest +import androidx.credentials.provider.BeginGetCredentialRequest +import androidx.credentials.provider.BeginGetCredentialResponse +import androidx.credentials.provider.BeginGetPublicKeyCredentialOption +import androidx.credentials.provider.CreateEntry +import androidx.credentials.provider.CredentialEntry +import androidx.credentials.provider.CredentialProviderService +import androidx.credentials.provider.ProviderClearCredentialStateRequest +import androidx.credentials.provider.PublicKeyCredentialEntry +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildIcon +import com.kunzisoft.keepass.credentialprovider.SpecialMode +import com.kunzisoft.keepass.credentialprovider.activity.PasskeyLauncherActivity +import com.kunzisoft.keepass.credentialprovider.passkey.util.JsonHelper +import com.kunzisoft.keepass.database.ContextualDatabase +import com.kunzisoft.keepass.database.DatabaseTaskProvider +import com.kunzisoft.keepass.database.helper.SearchHelper +import com.kunzisoft.keepass.model.EntryInfoPasskey.getPasskey +import com.kunzisoft.keepass.model.SearchInfo +import java.time.Instant + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +class PasskeyProviderService : CredentialProviderService() { + + private var mDatabaseTaskProvider: DatabaseTaskProvider? = null + private var mDatabase: ContextualDatabase? = null + private lateinit var defaultIcon: Icon + + override fun onCreate() { + super.onCreate() + + mDatabaseTaskProvider = DatabaseTaskProvider(this) + mDatabaseTaskProvider?.registerProgressTask() + mDatabaseTaskProvider?.onDatabaseRetrieved = { database -> + this.mDatabase = database + } + + defaultIcon = Icon.createWithResource( + this@PasskeyProviderService, + R.mipmap.ic_launcher_round + ).apply { + setTintBlendMode(BlendMode.DST) + } + } + + override fun onDestroy() { + mDatabaseTaskProvider?.unregisterProgressTask() + super.onDestroy() + } + + override fun onBeginGetCredentialRequest( + request: BeginGetCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + Log.d(javaClass.simpleName, "onBeginGetCredentialRequest called") + processGetCredentialsRequest(request)?.let { response -> + callback.onResult(response) + } ?: run { + callback.onError(GetCredentialUnknownException()) + } + } + + private fun processGetCredentialsRequest(request: BeginGetCredentialRequest): BeginGetCredentialResponse? { + val credentialEntries: MutableList = mutableListOf() + + for (option in request.beginGetCredentialOptions) { + when (option) { + is BeginGetPublicKeyCredentialOption -> { + credentialEntries.addAll( + populatePasskeyData(option) + ) + return BeginGetCredentialResponse(credentialEntries) + } + } + } + Log.w(javaClass.simpleName, "unknown beginGetCredentialOption") + return null + } + + private fun populatePasskeyData(option: BeginGetPublicKeyCredentialOption): List { + + val passkeyEntries: MutableList = mutableListOf() + + val relyingPartyJson = JsonHelper + .parseJsonToRequestOptions(option.requestJson) + .relyingParty + val searchInfo = SearchInfo().apply { + relyingParty = relyingPartyJson + } + Log.d(TAG, "Build passkey search for relying party $relyingPartyJson") + SearchHelper.checkAutoSearchInfo( + context = this, + database = mDatabase, + searchInfo = searchInfo, + onItemsFound = { database, items -> + Log.d(TAG, "Add pending intent for passkey selection with found items") + for (passkeyEntry in items) { + PasskeyLauncherActivity.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.SELECTION, + passkeyEntryNodeId = passkeyEntry.id + )?.let { usagePendingIntent -> + val passkey = passkeyEntry.getPasskey() + passkeyEntries.add( + PublicKeyCredentialEntry( + context = applicationContext, + username = passkey?.username ?: "Unknown", + icon = passkeyEntry.buildIcon(this@PasskeyProviderService, database)?.apply { + setTintBlendMode(BlendMode.DST) + } ?: defaultIcon, + pendingIntent = usagePendingIntent, + beginGetPublicKeyCredentialOption = option, + displayName = passkey?.displayName, + isAutoSelectAllowed = false + ) + ) + } + } + }, + onItemNotFound = { _ -> + Log.w(TAG, "No passkey found in the database with this relying party : $relyingPartyJson") + Log.d(TAG, "Add pending intent for passkey selection in opened database") + PasskeyLauncherActivity.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.SELECTION, + searchInfo = searchInfo + )?.let { pendingIntent -> + passkeyEntries.add( + PublicKeyCredentialEntry( + context = applicationContext, + username = getString(R.string.passkey_locked_database_username), + displayName = getString(R.string.passkey_selection_description), + icon = defaultIcon, + pendingIntent = pendingIntent, + beginGetPublicKeyCredentialOption = option, + lastUsedTime = Instant.now(), + isAutoSelectAllowed = false + ) + ) + } + }, + onDatabaseClosed = { + Log.d(TAG, "Add pending intent for passkey selection in closed database") + // Database is locked, a public key credential entry is shown to unlock it + PasskeyLauncherActivity.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.SELECTION, + searchInfo = searchInfo + )?.let { pendingIntent -> + passkeyEntries.add( + PublicKeyCredentialEntry( + context = applicationContext, + username = getString(R.string.passkey_locked_database_username), + displayName = getString(R.string.passkey_locked_database_description), + icon = defaultIcon, + pendingIntent = pendingIntent, + beginGetPublicKeyCredentialOption = option, + lastUsedTime = Instant.now(), + isAutoSelectAllowed = true + ) + ) + } + } + ) + return passkeyEntries + } + + override fun onBeginCreateCredentialRequest( + request: BeginCreateCredentialRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver, + ) { + Log.d(javaClass.simpleName, "onBeginCreateCredentialRequest called") + processCreateCredentialRequest(request)?.let { response -> + callback.onResult(response) + } ?: let { + callback.onError(CreateCredentialUnknownException()) + } + } + + private fun processCreateCredentialRequest(request: BeginCreateCredentialRequest): BeginCreateCredentialResponse? { + when (request) { + is BeginCreatePublicKeyCredentialRequest -> { + // Request is passkey type + return handleCreatePasskeyQuery(request) + } + } + // request type not supported + Log.w(javaClass.simpleName, "unknown type of BeginCreateCredentialRequest") + return null + } + + private fun MutableList.addPendingIntentCreationNewEntry( + accountName: String, + searchInfo: SearchInfo? + ) { + Log.d(TAG, "Add pending intent for registration in opened database to create new item") + // TODO add a setting to directly store in a specific group + PasskeyLauncherActivity.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.REGISTRATION, + searchInfo = searchInfo + )?.let { pendingIntent -> + this.add( + CreateEntry( + accountName = accountName, + icon = defaultIcon, + pendingIntent = pendingIntent, + description = getString(R.string.passkey_creation_description) + ) + ) + } + } + + private fun handleCreatePasskeyQuery(request: BeginCreatePublicKeyCredentialRequest): BeginCreateCredentialResponse { + + val accountName = mDatabase?.name ?: getString(R.string.passkey_locked_database_username) + val createEntries: MutableList = mutableListOf() + val searchInfo = SearchInfo().apply { + relyingParty = JsonHelper + .parseJsonToCreateOptions(request.requestJson) + .relyingParty + } + SearchHelper.checkAutoSearchInfo( + context = this, + database = mDatabase, + searchInfo = searchInfo, + onItemsFound = { database, items -> + if (database.isReadOnly) { + throw CreateCredentialUnknownException( + "Unable to register or overwrite a passkey in a database that is read only" + ) + } else { + // To create a new entry + createEntries.addPendingIntentCreationNewEntry(accountName, searchInfo) + /* TODO Overwrite + // To select an existing entry and permit an overwrite + Log.w(TAG, "Passkey already registered") + for (entryInfo in items) { + PasskeyHelper.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.REGISTRATION, + searchInfo = searchInfo, + passkeyEntryNodeId = entryInfo.id + )?.let { createPendingIntent -> + createEntries.add( + CreateEntry( + accountName = accountName, + pendingIntent = createPendingIntent, + description = getString( + R.string.passkey_update_description, + entryInfo.getPasskey()?.displayName + ) + ) + ) + } + }*/ + } + }, + onItemNotFound = { database -> + // To create a new entry + if (database.isReadOnly) { + throw CreateCredentialUnknownException( + "Unable to register a new passkey in a database that is read only" + ) + } else { + createEntries.addPendingIntentCreationNewEntry(accountName, searchInfo) + } + }, + onDatabaseClosed = { + // Launch the passkey launcher activity to open the database + Log.d(TAG, "Add pending intent for passkey registration in closed database") + PasskeyLauncherActivity.getPendingIntent( + context = applicationContext, + specialMode = SpecialMode.REGISTRATION + )?.let { pendingIntent -> + createEntries.add( + CreateEntry( + accountName = accountName, + icon = defaultIcon, + pendingIntent = pendingIntent, + description = getString(R.string.passkey_locked_database_description) + ) + ) + } + } + ) + + return BeginCreateCredentialResponse(createEntries) + } + + override fun onClearCredentialStateRequest( + request: ProviderClearCredentialStateRequest, + cancellationSignal: CancellationSignal, + callback: OutcomeReceiver + ) { + // nothing to do + } + + companion object { + private val TAG = PasskeyProviderService::class.java.simpleName + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt new file mode 100644 index 000000000..a5f9fcc81 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt @@ -0,0 +1,9 @@ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +data class PublicKeyCredentialCreationOptions( + val relyingParty: String, + val challenge: ByteArray, // TODO Equals Hashcode + val username: String, + val userId: ByteArray, // TODO Equals Hashcode + val keyTypeIdList: List +) \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationParameters.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationParameters.kt new file mode 100644 index 000000000..a92030ad0 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationParameters.kt @@ -0,0 +1,11 @@ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import java.security.KeyPair + +data class PublicKeyCredentialCreationParameters( + val relyingParty: String, + val credentialId: ByteArray, // TODO Equals Hashcode + val signatureKey: Pair, + val isPrivilegedApp: Boolean, + val challenge: ByteArray, // TODO Equals Hashcode +) \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialRequestOptions.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialRequestOptions.kt similarity index 63% rename from app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialRequestOptions.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialRequestOptions.kt index 5a740d5d4..686a7f866 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/data/PublicKeyCredentialRequestOptions.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialRequestOptions.kt @@ -1,7 +1,6 @@ -package com.kunzisoft.keepass.credentialprovider.data +package com.kunzisoft.keepass.credentialprovider.passkey.data data class PublicKeyCredentialRequestOptions( val relyingParty: String, val challengeString: String -) { -} \ No newline at end of file +) \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialUsageParameters.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialUsageParameters.kt new file mode 100644 index 000000000..e67a42578 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialUsageParameters.kt @@ -0,0 +1,9 @@ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +data class PublicKeyCredentialUsageParameters( + val relyingParty: String, + val packageName: String? = null, + val clientDataHash: ByteArray?, // TODO Equals Hashcode + val isPrivilegedApp: Boolean, + val challenge: ByteArray, // TODO Equals Hashcode +) \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/AppRelyingPartyRelation.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/AppRelyingPartyRelation.kt similarity index 90% rename from app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/AppRelyingPartyRelation.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/AppRelyingPartyRelation.kt index 127bc9f03..a4a5e4f49 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/AppRelyingPartyRelation.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/AppRelyingPartyRelation.kt @@ -1,4 +1,4 @@ -package com.kunzisoft.keepass.credentialprovider.util +package com.kunzisoft.keepass.credentialprovider.passkey.util class AppRelyingPartyRelation { diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/Base64Helper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/Base64Helper.kt similarity index 88% rename from app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/Base64Helper.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/Base64Helper.kt index e50bda5dd..fd83a3f2f 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/Base64Helper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/Base64Helper.kt @@ -1,4 +1,4 @@ -package com.kunzisoft.keepass.credentialprovider.util +package com.kunzisoft.keepass.credentialprovider.passkey.util import org.apache.commons.codec.binary.Base64 diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/JsonHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/JsonHelper.kt similarity index 95% rename from app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/JsonHelper.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/JsonHelper.kt index 4df8a7a58..e080d32b5 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/JsonHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/JsonHelper.kt @@ -1,12 +1,12 @@ -package com.kunzisoft.keepass.credentialprovider.util +package com.kunzisoft.keepass.credentialprovider.passkey.util import android.annotation.SuppressLint import androidx.credentials.webauthn.Cbor import com.kunzisoft.encrypt.HashManager -import com.kunzisoft.keepass.credentialprovider.data.PublicKeyCredentialCreationOptions -import com.kunzisoft.keepass.credentialprovider.data.PublicKeyCredentialRequestOptions -import com.kunzisoft.keepass.credentialprovider.util.Base64Helper.Companion.b64Decode -import com.kunzisoft.keepass.credentialprovider.util.Base64Helper.Companion.b64Encode +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationOptions +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRequestOptions +import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper.Companion.b64Decode +import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper.Companion.b64Encode import org.json.JSONArray import org.json.JSONObject @@ -245,7 +245,5 @@ class JsonHelper { keyTypeIdList.distinct() ) } - - } } \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/OriginHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginHelper.kt similarity index 61% rename from app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/OriginHelper.kt rename to app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginHelper.kt index 69383a6a4..6d1895964 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/OriginHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginHelper.kt @@ -1,4 +1,4 @@ -package com.kunzisoft.keepass.credentialprovider.util +package com.kunzisoft.keepass.credentialprovider.passkey.util import android.content.res.AssetManager import androidx.credentials.provider.CallingAppInfo @@ -9,13 +9,12 @@ class OriginHelper { const val DEFAULT_PROTOCOL = "https://" - fun getWebOrigin(callingAppInfo: CallingAppInfo, assets: AssetManager): String? { + fun getWebOrigin(callingAppInfo: CallingAppInfo?, assets: AssetManager): String? { val privilegedAllowlist = assets.open("trustedPackages.json").bufferedReader().use { it.readText() } // for trusted browsers like Chrome and Firefox - val origin = callingAppInfo.getOrigin(privilegedAllowlist)?.removeSuffix("/") - return origin + return callingAppInfo?.getOrigin(privilegedAllowlist)?.removeSuffix("/") } } diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt new file mode 100644 index 000000000..6ff17147d --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt @@ -0,0 +1,428 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.util + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.os.ParcelUuid +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.provider.PendingIntentHandler +import com.kunzisoft.asymmetric.Signature +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationOptions +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters +import com.kunzisoft.keepass.credentialprovider.passkey.util.OriginHelper.Companion.DEFAULT_PROTOCOL +import com.kunzisoft.keepass.model.EntryInfo +import com.kunzisoft.keepass.model.EntryInfoPasskey.getPasskey +import com.kunzisoft.keepass.model.Passkey +import com.kunzisoft.keepass.utils.StringUtil.toHexString +import com.kunzisoft.keepass.utils.getParcelableExtraCompat +import com.kunzisoft.random.KeePassDXRandom +import java.security.KeyStore +import java.security.MessageDigest +import java.time.Instant +import java.util.UUID +import javax.crypto.KeyGenerator +import javax.crypto.Mac +import javax.crypto.SecretKey + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +object PasskeyHelper { + + private const val EXTRA_PASSKEY_ELEMENT = "com.kunzisoft.keepass.passkey.extra.EXTRA_PASSKEY_ELEMENT" + + private const val HMAC_TYPE = "HmacSHA256" + + private const val KEY_NODE_ID = "nodeId" + private const val KEY_TIMESTAMP = "timestamp" + private const val KEY_AUTHENTICATION_CODE = "authenticationCode" + + private const val SEPARATOR = "_" + + private const val NAME_OF_HMAC_KEY = "KeePassDXCredentialProviderHMACKey" + + private const val KEYSTORE_TYPE = "AndroidKeyStore" + + private val PLACEHOLDER_FOR_NEW_NODE_ID = "0".repeat(32) + + private val REGEX_TIMESTAMP = "[0-9]{10}".toRegex() + private val REGEX_AUTHENTICATION_CODE = "[A-F0-9]{64}".toRegex() // 256 bits = 64 hex chars + + private const val MAX_DIFF_IN_SECONDS = 60 + + /** + * Build the Passkey response for one entry + */ + fun Activity.buildPasskeyResponseAndSetResult( + entryInfo: EntryInfo, + extras: Bundle? = null + ) { + try { + entryInfo.getPasskey()?.let { + val mReplyIntent = Intent() + Log.d(javaClass.name, "Success Passkey manual selection") + mReplyIntent.putExtra(EXTRA_PASSKEY_ELEMENT, entryInfo.getPasskey()) + extras?.let { + mReplyIntent.putExtras(it) + } + setResult(Activity.RESULT_OK, mReplyIntent) + } ?: run { + Log.w(javaClass.name, "Failed Passkey manual selection") + setResult(Activity.RESULT_CANCELED) + } + } catch (e: Exception) { + Log.e(javaClass.name, "Cant add passkey entry as result", e) + setResult(Activity.RESULT_CANCELED) + } + } + + fun Intent.addAuthCode(passkeyEntryNodeId: UUID? = null) { + passkeyEntryNodeId?.let { + putExtras(Bundle().apply { + val timestamp = Instant.now().epochSecond + putParcelable(KEY_NODE_ID, ParcelUuid(passkeyEntryNodeId)) + putString(KEY_TIMESTAMP, timestamp.toString()) + putString( + KEY_AUTHENTICATION_CODE, generatedAuthenticationCode( + passkeyEntryNodeId, timestamp + ).toHexString() + ) + }) + } + } + + fun Intent.retrievePasskey(): Passkey? { + return this.getParcelableExtraCompat(EXTRA_PASSKEY_ELEMENT) + } + + fun Intent.removePasskey() { + return this.removeExtra(EXTRA_PASSKEY_ELEMENT) + } + + fun Intent.retrieveNodeId(): UUID? { + return getParcelableExtraCompat(KEY_NODE_ID)?.uuid + } + + fun checkSecurity(intent: Intent, nodeId: UUID?) { + val timestampString = intent.getStringExtra(KEY_TIMESTAMP) + if (timestampString.isNullOrEmpty()) + throw CreateCredentialUnknownException("Timestamp null") + if (timestampString.matches(REGEX_TIMESTAMP).not()) { + throw CreateCredentialUnknownException("Timestamp not valid") + } + val timestamp = timestampString.toLong() + val diff = Instant.now().epochSecond - timestamp + if (diff < 0 || diff > MAX_DIFF_IN_SECONDS) { + throw CreateCredentialUnknownException("Out of time") + } + + verifyAuthenticationCode( + intent.getStringExtra(KEY_AUTHENTICATION_CODE), + generatedAuthenticationCode(nodeId, timestamp) + ) + } + + private fun generatedAuthenticationCode(nodeId: UUID?, timestamp: Long): ByteArray { + return generateAuthenticationCode( + (nodeId?.toString() ?: PLACEHOLDER_FOR_NEW_NODE_ID) + SEPARATOR + timestamp.toString() + ) + } + + private fun verifyAuthenticationCode( + valueToCheck: String?, + authenticationCode: ByteArray + ) { + if (valueToCheck.isNullOrEmpty()) + throw CreateCredentialUnknownException("Authentication code empty") + if (valueToCheck.matches(REGEX_AUTHENTICATION_CODE).not()) + throw CreateCredentialUnknownException("Authentication not valid") + if (MessageDigest.isEqual(authenticationCode, generateAuthenticationCode(valueToCheck))) + throw CreateCredentialUnknownException("Authentication code incorrect") + } + + private fun generateAuthenticationCode(message: String): ByteArray { + val keyStore = KeyStore.getInstance(KEYSTORE_TYPE) + keyStore.load(null) + val hmacKey = try { + keyStore.getKey(NAME_OF_HMAC_KEY, null) as SecretKey + } catch (e: Exception) { + // key not found + generateKey() + } + + val mac = Mac.getInstance(HMAC_TYPE) + mac.init(hmacKey) + val authenticationCode = mac.doFinal(message.toByteArray()) + return authenticationCode + } + + private fun generateKey(): SecretKey? { + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE_TYPE + ) + val keySizeInBits = 128 + keyGenerator.init( + KeyGenParameterSpec.Builder(NAME_OF_HMAC_KEY, KeyProperties.PURPOSE_SIGN) + .setKeySize(keySizeInBits) + .build() + ) + val key = keyGenerator.generateKey() + return key + } + + private fun String.decodeHexToByteArray(): ByteArray { + if (length % 2 != 0) { + throw IllegalArgumentException("Must have an even length") + } + return chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + + fun Intent.retrievePasskeyCreationComponent(): PublicKeyCredentialCreationOptions { + val request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(this) + ?: throw CreateCredentialUnknownException("could not retrieve request from intent") + if (request.callingRequest !is CreatePublicKeyCredentialRequest) { + throw CreateCredentialUnknownException("callingRequest is of wrong type: ${request.callingRequest.type}") + } + return JsonHelper.parseJsonToCreateOptions( + (request.callingRequest as CreatePublicKeyCredentialRequest).requestJson + ) + } + + fun Intent.retrievePasskeyUsageComponent(): GetPublicKeyCredentialOption { + val request = PendingIntentHandler.retrieveProviderGetCredentialRequest(this) + ?: throw CreateCredentialUnknownException("could not retrieve request from intent") + if (request.credentialOptions.size != 1) { + throw GetCredentialUnknownException("not exact one credentialOption") + } + if (request.credentialOptions[0] !is GetPublicKeyCredentialOption) { + throw CreateCredentialUnknownException("credentialOptions is of wrong type: ${request.credentialOptions[0]}") + } + return request.credentialOptions[0] as GetPublicKeyCredentialOption + } + + fun retrievePasskeyCreationRequestParameters( + creationOptions: PublicKeyCredentialCreationOptions, + webOrigin: String?, + apkSigningCertificate: ByteArray?, + passkeyCreated: (Passkey, PublicKeyCredentialCreationParameters) -> Unit + ) { + val relyingParty = creationOptions.relyingParty + val username = creationOptions.username + val userHandle = creationOptions.userId + val keyTypeIdList = creationOptions.keyTypeIdList + val challenge = creationOptions.challenge + + val isPrivilegedApp = + (webOrigin != null && webOrigin == DEFAULT_PROTOCOL + relyingParty) + Log.d(this::class.java.simpleName, "isPrivilegedApp = $isPrivilegedApp") + + if (!isPrivilegedApp) { + val isValid = + AppRelyingPartyRelation.isRelationValid(relyingParty, apkSigningCertificate) + if (!isValid) { + throw CreateCredentialUnknownException( + "could not verify relation between app " + + "and relyingParty $relyingParty" + ) + } + } + + val credentialId = KeePassDXRandom.generateCredentialId() + + val (keyPair, keyTypeId) = Signature.generateKeyPair(keyTypeIdList) + ?: throw CreateCredentialUnknownException("no known public key type found") + val privateKeyPem = Signature.convertPrivateKeyToPem(keyPair.private) + + // create new entry in database + passkeyCreated.invoke( + Passkey( + username = username, + displayName = "$relyingParty (Passkey)", + privateKeyPem = privateKeyPem, + credentialId = Base64Helper.b64Encode(credentialId), + userHandle = Base64Helper.b64Encode(userHandle), + relyingParty = DEFAULT_PROTOCOL + relyingParty + ), + PublicKeyCredentialCreationParameters( + relyingParty = relyingParty, + challenge = challenge, + credentialId = credentialId, + signatureKey = Pair(keyPair, keyTypeId), + isPrivilegedApp = isPrivilegedApp + ) + ) + } + + fun buildCreatePublicKeyCredentialResponse( + packageName: String?, + publicKeyCredentialCreationParameters: PublicKeyCredentialCreationParameters + ): CreatePublicKeyCredentialResponse { + + val keyPair = publicKeyCredentialCreationParameters.signatureKey.first + val keyTypeId = publicKeyCredentialCreationParameters.signatureKey.second + + val publicKeyEncoded = Signature.convertPublicKey(keyPair.public, keyTypeId) + val publicKeyMap = Signature.convertPublicKeyToMap(keyPair.public, keyTypeId) + + val authData = JsonHelper.generateAuthDataForCreate( + userPresent = true, + userVerified = true, + backupEligibility = true, + backupState = true, + rpId = publicKeyCredentialCreationParameters.relyingParty.toByteArray(), + credentialId = publicKeyCredentialCreationParameters.credentialId, + credentialPublicKey = JsonHelper.generateCborFromMap(publicKeyMap!!) + ) + + val attestationObject = JsonHelper.generateAttestationObject(authData) + + val clientJson: String + if (publicKeyCredentialCreationParameters.isPrivilegedApp) { + clientJson = JsonHelper.generateClientDataJsonPrivileged() + } else { + val origin = DEFAULT_PROTOCOL + publicKeyCredentialCreationParameters.relyingParty + clientJson = JsonHelper.generateClientDataJsonNonPrivileged( + publicKeyCredentialCreationParameters.challenge, + origin, + packageName, + isCrossOriginAdded = true, + isGet = false + ) + } + + val responseJson = JsonHelper.createAuthenticatorAttestationResponseJSON( + publicKeyCredentialCreationParameters.credentialId, + clientJson, + attestationObject, + publicKeyEncoded!!, + authData, + keyTypeId + ) + + // log only the length to prevent logging sensitive information + Log.d(javaClass.simpleName, "responseJson with length ${responseJson.length} created") + return CreatePublicKeyCredentialResponse(responseJson) + } + + fun retrievePasskeyUsageRequestParameters( + context: Context, + intent: Intent, + result: (PublicKeyCredentialUsageParameters) -> Unit + ) { + val callingAppInfo = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)?.callingAppInfo + val credentialOption = intent.retrievePasskeyUsageComponent() + val clientDataHash = credentialOption.clientDataHash + + val requestOptions = JsonHelper.parseJsonToRequestOptions(credentialOption.requestJson) + + val relyingParty = requestOptions.relyingParty + val challenge = Base64Helper.b64Decode(requestOptions.challengeString) + val packageName = callingAppInfo?.packageName + val webOrigin = OriginHelper.getWebOrigin(callingAppInfo, context.assets) + + val isPrivilegedApp = + (webOrigin != null && webOrigin == DEFAULT_PROTOCOL + relyingParty && clientDataHash != null) + + Log.d(javaClass.simpleName, "isPrivilegedApp = $isPrivilegedApp") + + if (!isPrivilegedApp) { + if (!AppRelyingPartyRelation.isRelationValid( + relyingParty, + apkSigningCertificate = callingAppInfo?.signingInfo?.apkContentsSigners + ?.getOrNull(0)?.toByteArray() + )) { + throw CreateCredentialUnknownException( + "could not verify relation between app " + + "and relyingParty $relyingParty" + ) + } + } + + result.invoke( + PublicKeyCredentialUsageParameters( + relyingParty = relyingParty, + packageName = packageName, + clientDataHash = clientDataHash, + isPrivilegedApp = isPrivilegedApp, + challenge = challenge + ) + ) + } + + fun buildPasskeyPublicKeyCredential( + usageParameters: PublicKeyCredentialUsageParameters, + passkey: Passkey + ): PublicKeyCredential { + + // https://www.w3.org/TR/webauthn-3/#authdata-flags + val authenticatorData = JsonHelper.generateAuthDataForUsage( + usageParameters.relyingParty.toByteArray(), + userPresent = true, + userVerified = true, + backupEligibility = true, + backupState = true + ) + + val clientDataJson: String + val dataToSign: ByteArray + if (usageParameters.isPrivilegedApp) { + clientDataJson = JsonHelper.generateClientDataJsonPrivileged() + dataToSign = + JsonHelper.generateDataToSignPrivileged(usageParameters.clientDataHash!!, authenticatorData) + } else { + val origin = DEFAULT_PROTOCOL + usageParameters.relyingParty + clientDataJson = JsonHelper.generateClientDataJsonNonPrivileged( + usageParameters.challenge, + origin, + usageParameters.packageName, + isGet = true, + isCrossOriginAdded = false + ) + dataToSign = + JsonHelper.generateDataTosSignNonPrivileged(clientDataJson, authenticatorData) + } + + val signature = Signature.sign(passkey.privateKeyPem, dataToSign) + ?: throw GetCredentialUnknownException("signing failed") + + val getCredentialResponse = + JsonHelper.generateGetCredentialResponse( + clientDataJson.toByteArray(), + authenticatorData, + signature, + passkey.userHandle, + passkey.credentialId + ) + return PublicKeyCredential(getCredentialResponse) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/service/KeePassDXCredentialProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/service/KeePassDXCredentialProviderService.kt deleted file mode 100644 index e8613a517..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/service/KeePassDXCredentialProviderService.kt +++ /dev/null @@ -1,219 +0,0 @@ -package com.kunzisoft.keepass.credentialprovider.service - -import android.os.Build -import android.os.CancellationSignal -import android.os.OutcomeReceiver -import android.util.Log -import androidx.annotation.RequiresApi -import androidx.credentials.exceptions.ClearCredentialException -import androidx.credentials.exceptions.CreateCredentialException -import androidx.credentials.exceptions.CreateCredentialUnknownException -import androidx.credentials.exceptions.GetCredentialException -import androidx.credentials.exceptions.GetCredentialUnknownException -import androidx.credentials.provider.BeginCreateCredentialRequest -import androidx.credentials.provider.BeginCreateCredentialResponse -import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest -import androidx.credentials.provider.BeginGetCredentialRequest -import androidx.credentials.provider.BeginGetCredentialResponse -import androidx.credentials.provider.BeginGetPublicKeyCredentialOption -import androidx.credentials.provider.CreateEntry -import androidx.credentials.provider.CredentialEntry -import androidx.credentials.provider.CredentialProviderService -import androidx.credentials.provider.ProviderClearCredentialStateRequest -import androidx.credentials.provider.PublicKeyCredentialEntry -import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.credentialprovider.data.Passkey -import com.kunzisoft.keepass.credentialprovider.util.DatabaseHelper -import com.kunzisoft.keepass.credentialprovider.util.IntentHelper -import com.kunzisoft.keepass.credentialprovider.util.JsonHelper -import com.kunzisoft.keepass.database.DatabaseTaskProvider -import com.kunzisoft.keepass.database.element.Database -import java.time.Instant - -@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) -class KeePassDXCredentialProviderService : CredentialProviderService() { - - private var mDatabaseTaskProvider: DatabaseTaskProvider? = null - private var mDatabase: Database? = null - - override fun onCreate() { - super.onCreate() - - mDatabaseTaskProvider = DatabaseTaskProvider(this) - mDatabaseTaskProvider?.registerProgressTask() - mDatabaseTaskProvider?.onDatabaseRetrieved = { database -> - this.mDatabase = database - } - } - - override fun onDestroy() { - mDatabaseTaskProvider?.unregisterProgressTask() - super.onDestroy() - } - - override fun onBeginCreateCredentialRequest( - request: BeginCreateCredentialRequest, - cancellationSignal: CancellationSignal, - callback: OutcomeReceiver, - ) { - Log.d(javaClass.simpleName, "onBeginCreateCredentialRequest called") - processCreateCredentialRequest(request)?.let { response -> - callback.onResult(response) - } ?: let { - callback.onError(CreateCredentialUnknownException()) - } - } - - private fun processCreateCredentialRequest(request: BeginCreateCredentialRequest): BeginCreateCredentialResponse? { - when (request) { - is BeginCreatePublicKeyCredentialRequest -> { - // Request is passkey type - return handleCreatePasskeyQuery(request) - } - } - // request type not supported - Log.w(javaClass.simpleName, "unknown type of BeginCreateCredentialRequest") - return null - } - - private fun handleCreatePasskeyQuery(request: BeginCreatePublicKeyCredentialRequest): BeginCreateCredentialResponse { - - val accountName = mDatabase?.name ?: getString(R.string.passkey_locked_database_account_name) - val createEntries: MutableList = mutableListOf() - - mDatabase?.let { database -> - // To create a new entry - IntentHelper.generateCreatePendingIntent(applicationContext) - ?.let { pendingIntentNewEntry -> - createEntries.add( - CreateEntry( - accountName = accountName, - pendingIntent = pendingIntentNewEntry, - description = getString(R.string.passkey_creation_description) - ) - ) - } - - // To select an existing entry - for (passkey in getCredentialsFromDb( - relyingPartyId = JsonHelper.parseJsonToCreateOptions(request.requestJson).relyingParty, - database = database - )) { - IntentHelper.generateCreatePendingIntent(applicationContext, passkey.nodeId) - ?.let { createPendingIntent -> - createEntries.add( - CreateEntry( - accountName = accountName, - pendingIntent = createPendingIntent, - description = getString( - R.string.passkey_update_description, - passkey.displayName - ) - ) - ) - } - } - } ?: run { - // Database is locked, an entry is shown to unlock it - createEntries.add( - CreateEntry( - accountName = accountName, - pendingIntent = IntentHelper.generateUnlockPendingIntent(applicationContext), - description = getString(R.string.passkey_locked_database_description) - ) - ) - } - - return BeginCreateCredentialResponse(createEntries) - } - - override fun onBeginGetCredentialRequest( - request: BeginGetCredentialRequest, - cancellationSignal: CancellationSignal, - callback: OutcomeReceiver, - ) { - Log.d(javaClass.simpleName, "onBeginGetCredentialRequest called") - processGetCredentialsRequest(request)?.let { response -> - callback.onResult(response) - } ?: run { - callback.onError(GetCredentialUnknownException()) - } - } - - private fun processGetCredentialsRequest(request: BeginGetCredentialRequest): BeginGetCredentialResponse? { - val credentialEntries: MutableList = mutableListOf() - - for (option in request.beginGetCredentialOptions) { - when (option) { - is BeginGetPublicKeyCredentialOption -> { - credentialEntries.addAll( - populatePasskeyData(option) - ) - return BeginGetCredentialResponse(credentialEntries) - } - } - } - Log.w(javaClass.simpleName, "unknown beginGetCredentialOption") - return null - } - - private fun populatePasskeyData(option: BeginGetPublicKeyCredentialOption): List { - - val passkeyEntries: MutableList = mutableListOf() - - mDatabase?.let { database -> - // Retrieve passkeys entries from database - val relyingParty = JsonHelper.parseJsonToRequestOptions(option.requestJson).relyingParty - if (relyingParty.isBlank()) { - throw CreateCredentialUnknownException("relying party id is null or blank") - } - for (passkey in getCredentialsFromDb( - relyingPartyId = relyingParty, - database = database - )) { - IntentHelper.generateUsagePendingIntent(applicationContext, passkey.nodeId) - ?.let { usagePendingIntent -> - passkeyEntries.add( - PublicKeyCredentialEntry( - context = applicationContext, - username = passkey.username, - pendingIntent = usagePendingIntent, - beginGetPublicKeyCredentialOption = option, - displayName = passkey.displayName, - isAutoSelectAllowed = false - ) - ) - } - } - } ?: run { - // Database is locked, a public key credential entry is shown to unlock it - passkeyEntries.add( - PublicKeyCredentialEntry( - context = applicationContext, - username = getString(R.string.passkey_locked_database_account_name), - pendingIntent = IntentHelper.generateUnlockPendingIntent(applicationContext), - beginGetPublicKeyCredentialOption = option, - displayName = getString(R.string.passkey_locked_database_description), - lastUsedTime = Instant.now(), - isAutoSelectAllowed = true - ) - ) - } - return passkeyEntries - } - - private fun getCredentialsFromDb(relyingPartyId: String, database: Database): List { - val passkeys = DatabaseHelper.getAllPasskeys(database) - val passkeysMatching = passkeys.filter { p -> p.relyingParty == relyingPartyId } - return passkeysMatching - } - - override fun onClearCredentialStateRequest( - request: ProviderClearCredentialStateRequest, - cancellationSignal: CancellationSignal, - callback: OutcomeReceiver - ) { - // nothing to do - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/DatabaseHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/DatabaseHelper.kt deleted file mode 100644 index 112683787..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/DatabaseHelper.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.kunzisoft.keepass.credentialprovider.util - -import android.os.Build -import android.util.Log -import androidx.annotation.RequiresApi -import com.kunzisoft.keepass.credentialprovider.data.Passkey -import com.kunzisoft.keepass.database.ContextualDatabase -import com.kunzisoft.keepass.database.DatabaseTaskProvider -import com.kunzisoft.keepass.database.element.Database -import com.kunzisoft.keepass.database.element.Entry -import com.kunzisoft.keepass.database.element.node.NodeIdUUID -import com.kunzisoft.keepass.database.search.SearchHelper -import com.kunzisoft.keepass.database.search.SearchParameters -import com.kunzisoft.keepass.utils.UuidUtil - -@RequiresApi(Build.VERSION_CODES.O) -class DatabaseHelper { - - companion object { - - fun getAllPasskeys(database: Database): List { - val searchHelper = SearchHelper() - val searchParameters = SearchParameters().apply { - searchQuery = PasskeyConverter.PASSKEY_TAG - searchInTitles = false - searchInUsernames = false - searchInPasswords = false - searchInUrls = false - searchInNotes = false - searchInOTP = false - searchInOther = false - searchInUUIDs = false - searchInTags = true - searchInCurrentGroup = false - searchInSearchableGroup = false - searchInRecycleBin = false - searchInTemplates = false - } - val fromGroup = null - val max = Int.MAX_VALUE - val searchResult = searchHelper.createVirtualGroupWithSearchResult( - database, - searchParameters, - fromGroup, - max - ) - ?: return emptyList() - - return PasskeyConverter.convertEntriesListToPasskeys(searchResult.getChildEntries()) - } - - fun searchPassKeyByNodeId(database: Database, nodeId: String): Passkey? { - val uuidToSearch = UuidUtil.fromHexString(nodeId) ?: return null - val nodeIdUUIDToSearch = NodeIdUUID(uuidToSearch) - val entry = database.getEntryById(nodeIdUUIDToSearch) ?: return null - return PasskeyConverter.convertEntryToPasskey(entry) - } - - fun updateEntry( - database: Database, - databaseTaskProvider: DatabaseTaskProvider, - updatedPasskey: Passkey - ) { - val oldEntry = Entry(updatedPasskey.databaseEntry!!) - val entryToUpdate = Entry(updatedPasskey.databaseEntry) - - PasskeyConverter.setPasskeyInEntry(updatedPasskey, entryToUpdate) - - entryToUpdate.setEntryInfo( - database, - entryToUpdate.getEntryInfo( - database, - raw = true, - removeTemplateConfiguration = false - ) - ) - - val save = true - databaseTaskProvider.startDatabaseUpdateEntry(oldEntry, entryToUpdate, save) - Log.d(this::class.java.simpleName, "passkey in entry ${oldEntry.title} updated") - } - - fun saveNewEntry( - database: ContextualDatabase, - databaseTaskProvider: DatabaseTaskProvider, - newPasskey: Passkey - ) { - val newEntry = database.createEntry() ?: throw Exception("can not create new entry") - PasskeyConverter.setPasskeyInEntry(newPasskey, newEntry) - val save = true - - val group = database.rootGroup - ?: throw Exception("can not save new entry in database, because rootGroup is null") - databaseTaskProvider.startDatabaseCreateEntry(newEntry, group, save) - Log.d(this::class.java.simpleName, "new entry saved") - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/IntentHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/IntentHelper.kt deleted file mode 100644 index 07512a0fd..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/IntentHelper.kt +++ /dev/null @@ -1,195 +0,0 @@ -package com.kunzisoft.keepass.credentialprovider.util - -import android.app.Activity -import android.app.PendingIntent -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.Bundle -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import android.util.Log -import androidx.annotation.RequiresApi -import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity -import com.kunzisoft.keepass.credentialprovider.activity.CreatePasskeyActivity -import com.kunzisoft.keepass.credentialprovider.activity.UsePasskeyActivity -import com.kunzisoft.keepass.utils.StringUtil.toHexString -import java.security.KeyStore -import java.security.MessageDigest -import java.time.Instant -import javax.crypto.KeyGenerator -import javax.crypto.Mac -import javax.crypto.SecretKey - -@RequiresApi(Build.VERSION_CODES.O) -class IntentHelper { - - companion object { - private const val HMAC_TYPE = "HmacSHA256" - - private const val KEY_NODE_ID = "nodeId" - private const val KEY_TIMESTAMP = "timestamp" - private const val KEY_AUTHENTICATION_CODE = "authenticationCode" - - private const val SEPARATOR = "_" - - private const val NAME_OF_HMAC_KEY = "KeePassDXCredentialProviderHMACKey" - - private const val KEYSTORE_TYPE = "AndroidKeyStore" - - private val PLACEHOLDER_FOR_NEW_NODE_ID = "0".repeat(32) - - private val REGEX_NODE_ID = "[A-F0-9]{32}".toRegex() - private val REGEX_TIMESTAMP = "[0-9]{10}".toRegex() - private val REGEX_AUTHENTICATION_CODE = "[A-F0-9]{64}".toRegex() // 256 bits = 64 hex chars - - private const val MAX_DIFF_IN_SECONDS = 60 - - private var currentRequestCode: Int = 0 - - private fun createPendingIntent( - clazz: Class, - applicationContext: Context, - data: Bundle? = null - ): PendingIntent { - val intent = Intent().setClass(applicationContext, clazz) - - data?.let { intent.putExtras(data) } - - val requestCode = currentRequestCode - // keeps the requestCodes unique, the limit is arbitrary - currentRequestCode = (currentRequestCode + 1) % 1000 - return PendingIntent.getActivity( - applicationContext, - requestCode, - intent, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - } - - private fun createPendingIntentWithAuthenticationCode( - clazz: Class, - applicationContext: Context, - nodeId: String - ): PendingIntent? { - if (nodeId.matches(REGEX_NODE_ID).not()) return null - - val data = Bundle() - val timestamp = Instant.now().epochSecond.toString() - data.putString(KEY_NODE_ID, nodeId) - data.putString(KEY_TIMESTAMP, timestamp) - - val message = nodeId + SEPARATOR + timestamp - val authenticationCode = generateAuthenticationCode(message).toHexString() - - data.putString(KEY_AUTHENTICATION_CODE, authenticationCode) - return createPendingIntent(clazz, applicationContext, data) - } - - fun generateUnlockPendingIntent(applicationContext: Context): PendingIntent { - // TODO after the database is unlocked by the user, return to the flow - return createPendingIntent(FileDatabaseSelectActivity::class.java, applicationContext) - } - - fun generateCreatePendingIntent( - applicationContext: Context, - nodeId: String = PLACEHOLDER_FOR_NEW_NODE_ID - ): PendingIntent? { - return createPendingIntentWithAuthenticationCode( - CreatePasskeyActivity::class.java, - applicationContext, - nodeId - ) - } - - fun generateUsagePendingIntent( - applicationContext: Context, - nodeId: String - ): PendingIntent? { - return createPendingIntentWithAuthenticationCode( - UsePasskeyActivity::class.java, - applicationContext, - nodeId - ) - } - - fun getVerifiedNodeId(intent: Intent): String? { - val nodeId = intent.getStringExtra(KEY_NODE_ID) ?: return null - val timestampString = intent.getStringExtra(KEY_TIMESTAMP) ?: return null - val authenticationCode = intent.getStringExtra(KEY_AUTHENTICATION_CODE) ?: return null - - if (nodeId.matches(REGEX_NODE_ID).not() || - timestampString.matches(REGEX_TIMESTAMP).not() || - authenticationCode.matches(REGEX_AUTHENTICATION_CODE).not() - ) { - return null - } - - val diff = Instant.now().epochSecond - timestampString.toLong() - if (diff < 0 || diff > MAX_DIFF_IN_SECONDS) { - return null - } - - val message = (nodeId + SEPARATOR + timestampString) - if (verifyAuthenticationCode( - message, - authenticationCode.decodeHexToByteArray() - ).not() - ) { - return null - } - Log.d(this::class.java.simpleName, "nodeId $nodeId verified") - return nodeId - } - - private fun verifyAuthenticationCode( - message: String, - authenticationCodeIn: ByteArray - ): Boolean { - val authenticationCode = generateAuthenticationCode(message) - return MessageDigest.isEqual(authenticationCodeIn, authenticationCode) - } - - private fun generateAuthenticationCode(message: String): ByteArray { - val keyStore = KeyStore.getInstance(KEYSTORE_TYPE) - keyStore.load(null) - val hmacKey = try { - keyStore.getKey(NAME_OF_HMAC_KEY, null) as SecretKey - } catch (e: Exception) { - // key not found - generateKey() - } - - val mac = Mac.getInstance(HMAC_TYPE) - mac.init(hmacKey) - val authenticationCode = mac.doFinal(message.toByteArray()) - return authenticationCode - } - - private fun generateKey(): SecretKey? { - val keyGenerator = KeyGenerator.getInstance( - KeyProperties.KEY_ALGORITHM_HMAC_SHA256, KEYSTORE_TYPE - ) - val keySizeInBits = 128 - keyGenerator.init( - KeyGenParameterSpec.Builder(NAME_OF_HMAC_KEY, KeyProperties.PURPOSE_SIGN) - .setKeySize(keySizeInBits) - .build() - ) - val key = keyGenerator.generateKey() - return key - } - - fun isPlaceholderNodeId(nodeId: String): Boolean { - return nodeId == PLACEHOLDER_FOR_NEW_NODE_ID - } - - private fun String.decodeHexToByteArray(): ByteArray { - if (length % 2 != 0) { - throw IllegalArgumentException("Must have an even length") - } - return chunked(2).map { it.toInt(16).toByte() }.toByteArray() - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/PasskeyConverter.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/PasskeyConverter.kt deleted file mode 100644 index 7418a4f94..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/util/PasskeyConverter.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.kunzisoft.keepass.credentialprovider.util - -import android.os.Build -import androidx.annotation.RequiresApi -import com.kunzisoft.keepass.credentialprovider.data.Passkey -import com.kunzisoft.keepass.database.element.DateInstant -import com.kunzisoft.keepass.database.element.Entry -import com.kunzisoft.keepass.database.element.Field -import com.kunzisoft.keepass.database.element.security.ProtectedString -import com.kunzisoft.keepass.utils.UuidUtil - -@RequiresApi(Build.VERSION_CODES.O) -class PasskeyConverter { - - companion object { - - // field names from KeypassXC are used - private const val FIELD_USERNAME = "KPEX_PASSKEY_USERNAME" - private const val FIELD_PRIVATE_KEY = "KPEX_PASSKEY_PRIVATE_KEY_PEM" - private const val FIELD_CREDENTIAL_ID = "KPEX_PASSKEY_CREDENTIAL_ID" - private const val FIELD_USER_HANDLE = "KPEX_PASSKEY_USER_HANDLE" - private const val FIELD_RELYING_PARTY = "KPEX_PASSKEY_RELYING_PARTY" - - const val PASSKEY_TAG = "Passkey" - - fun convertEntryToPasskey(entry: Entry): Passkey? { - if (entry.tags.toList().contains(PASSKEY_TAG).not()) { - return null - } - - val nodeId = UuidUtil.toHexString(entry.nodeId.id) ?: return null - - val displayName = entry.getVisualTitle() - - var username = "" - var privateKeyPem = "" - var credId = "" - var userHandle = "" - var relyingParty = "" - - for (field in entry.getExtraFields()) { - val fieldName = field.name - - if (fieldName == FIELD_USERNAME) { - username = field.protectedValue.stringValue - } else if (field.name == FIELD_PRIVATE_KEY) { - privateKeyPem = field.protectedValue.stringValue - } else if (field.name == FIELD_CREDENTIAL_ID) { - credId = field.protectedValue.stringValue - } else if (field.name == FIELD_USER_HANDLE) { - userHandle = field.protectedValue.stringValue - } else if (field.name == FIELD_RELYING_PARTY) { - relyingParty = field.protectedValue.stringValue - } - } - return Passkey( - nodeId, - username, - displayName, - privateKeyPem, - credId, - userHandle, - relyingParty, - entry - ) - } - - fun convertEntriesListToPasskeys(entries: List): List { - return entries.mapNotNull { e -> convertEntryToPasskey(e) } - } - - - fun setPasskeyInEntry(passkey: Passkey, entry: Entry) { - entry.tags.put(PASSKEY_TAG) - - entry.title = passkey.displayName - entry.lastModificationTime = DateInstant() - - entry.username = passkey.username - - entry.url = OriginHelper.DEFAULT_PROTOCOL + passkey.relyingParty - - val protected = true - val unProtected = false - - entry.putExtraField( - Field( - FIELD_USERNAME, - ProtectedString(unProtected, passkey.username) - ) - ) - entry.putExtraField( - Field( - FIELD_PRIVATE_KEY, - ProtectedString(protected, passkey.privateKeyPem) - ) - ) - entry.putExtraField( - Field( - FIELD_CREDENTIAL_ID, - ProtectedString(protected, passkey.credId) - ) - ) - entry.putExtraField( - Field( - FIELD_USER_HANDLE, - ProtectedString(protected, passkey.userHandle) - ) - ) - entry.putExtraField( - Field( - FIELD_RELYING_PARTY, - ProtectedString(unProtected, passkey.relyingParty) - ) - ) - } - - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/database/helper/SearchHelper.kt b/app/src/main/java/com/kunzisoft/keepass/database/helper/SearchHelper.kt index c4a90cf3c..5a7f846a0 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/helper/SearchHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/helper/SearchHelper.kt @@ -43,13 +43,15 @@ object SearchHelper { /** * Utility method to perform actions if item is found or not after an auto search in [database] */ - fun checkAutoSearchInfo(context: Context, - database: ContextualDatabase?, - searchInfo: SearchInfo?, - onItemsFound: (openedDatabase: ContextualDatabase, - items: List) -> Unit, - onItemNotFound: (openedDatabase: ContextualDatabase) -> Unit, - onDatabaseClosed: () -> Unit) { + fun checkAutoSearchInfo( + context: Context, + database: ContextualDatabase?, + searchInfo: SearchInfo?, + onItemsFound: (openedDatabase: ContextualDatabase, + items: List) -> Unit, + onItemNotFound: (openedDatabase: ContextualDatabase) -> Unit, + onDatabaseClosed: () -> Unit + ) { if (database == null || !database.loaded) { onDatabaseClosed.invoke() } else if (TimeoutHelper.checkTime(context)) { @@ -59,8 +61,7 @@ object SearchHelper { && !searchInfo.containsOnlyNullValues()) { // If search provide results database.createVirtualGroupFromSearchInfo( - searchInfo.toString(), - searchInfo.isASearchByDomain(), + searchInfo, MAX_SEARCH_ENTRY )?.let { searchGroup -> if (searchGroup.numberOfChildEntries > 0) { diff --git a/app/src/main/java/com/kunzisoft/keepass/receivers/DexModeReceiver.kt b/app/src/main/java/com/kunzisoft/keepass/receivers/DexModeReceiver.kt index 7844c4251..c2c4c5334 100644 --- a/app/src/main/java/com/kunzisoft/keepass/receivers/DexModeReceiver.kt +++ b/app/src/main/java/com/kunzisoft/keepass/receivers/DexModeReceiver.kt @@ -1,13 +1,9 @@ package com.kunzisoft.keepass.receivers import android.content.BroadcastReceiver -import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.util.Log -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService -import com.kunzisoft.keepass.utils.DexUtil import com.kunzisoft.keepass.utils.MagikeyboardUtil class DexModeReceiver : BroadcastReceiver() { diff --git a/app/src/main/java/com/kunzisoft/keepass/services/KeyboardEntryNotificationService.kt b/app/src/main/java/com/kunzisoft/keepass/services/KeyboardEntryNotificationService.kt index 9986b304a..557f00cd7 100644 --- a/app/src/main/java/com/kunzisoft/keepass/services/KeyboardEntryNotificationService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/services/KeyboardEntryNotificationService.kt @@ -27,7 +27,7 @@ import android.util.Log import android.widget.Toast import androidx.preference.PreferenceManager import com.kunzisoft.keepass.R -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService +import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.model.EntryInfo import com.kunzisoft.keepass.settings.PreferencesUtil import com.kunzisoft.keepass.timeout.TimeoutHelper diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/BroadcastAction.kt b/app/src/main/java/com/kunzisoft/keepass/utils/BroadcastAction.kt index 8d42310fa..727f3a998 100644 --- a/app/src/main/java/com/kunzisoft/keepass/utils/BroadcastAction.kt +++ b/app/src/main/java/com/kunzisoft/keepass/utils/BroadcastAction.kt @@ -32,7 +32,7 @@ import android.util.Log import androidx.core.content.ContextCompat import com.kunzisoft.keepass.R import com.kunzisoft.keepass.database.ContextualDatabase -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService +import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService import com.kunzisoft.keepass.services.ClipboardEntryNotificationService import com.kunzisoft.keepass.services.KeyboardEntryNotificationService import com.kunzisoft.keepass.settings.PreferencesUtil diff --git a/app/src/main/java/com/kunzisoft/keepass/utils/MagikeyboardUtil.kt b/app/src/main/java/com/kunzisoft/keepass/utils/MagikeyboardUtil.kt index be095f648..e98c2831b 100644 --- a/app/src/main/java/com/kunzisoft/keepass/utils/MagikeyboardUtil.kt +++ b/app/src/main/java/com/kunzisoft/keepass/utils/MagikeyboardUtil.kt @@ -4,7 +4,7 @@ import android.content.ComponentName import android.content.Context import android.content.pm.PackageManager import android.util.Log -import com.kunzisoft.keepass.magikeyboard.MagikeyboardService +import com.kunzisoft.keepass.credentialprovider.magikeyboard.MagikeyboardService object MagikeyboardUtil { private val TAG = MagikeyboardUtil::class.java.name diff --git a/app/src/main/res/layout/keyboard_container.xml b/app/src/main/res/layout/keyboard_container.xml index f8a29f13d..38461bc3c 100644 --- a/app/src/main/res/layout/keyboard_container.xml +++ b/app/src/main/res/layout/keyboard_container.xml @@ -93,7 +93,7 @@ android:maxLines="1" android:ellipsize="end" android:textColor="@color/grey_blue_slighter"/> - Expired entries are not shown Hide templates Templates are not shown - Confirm passkey usage - for %1$s - Cancel - Confirm passkey creation - for %1$s - Cancel + Passkey + KeePassDX Credential Provider Save passkey in new entry Update passkey in "%1$s" + No passkey found + Select an existing passkey + KeePassDX Database Locked Select to unlock - KeePassDX Database Locked \ No newline at end of file diff --git a/database/build.gradle b/database/build.gradle index f0d7adb9b..d7094f01b 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -1,5 +1,6 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' +apply plugin: 'kotlin-parcelize' android { namespace 'com.kunzisoft.keepass.database' diff --git a/database/src/main/java/com/kunzisoft/keepass/database/element/Database.kt b/database/src/main/java/com/kunzisoft/keepass/database/element/Database.kt index 8e19954a6..bc8e143c5 100644 --- a/database/src/main/java/com/kunzisoft/keepass/database/element/Database.kt +++ b/database/src/main/java/com/kunzisoft/keepass/database/element/Database.kt @@ -55,6 +55,7 @@ import com.kunzisoft.keepass.database.merge.DatabaseKDBXMerger import com.kunzisoft.keepass.database.search.SearchHelper import com.kunzisoft.keepass.database.search.SearchParameters import com.kunzisoft.keepass.hardware.HardwareKey +import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.tasks.ProgressTaskUpdater import com.kunzisoft.keepass.utils.SingletonHolder import com.kunzisoft.keepass.utils.StringUtil.toFormattedColorInt @@ -885,28 +886,15 @@ open class Database { } fun createVirtualGroupFromSearchInfo( - searchInfoString: String, - searchInfoByDomain: Boolean, + searchInfo: SearchInfo, max: Int = Integer.MAX_VALUE ): Group? { - return mSearchHelper.createVirtualGroupWithSearchResult(this, - SearchParameters().apply { - searchQuery = searchInfoString - searchInTitles = true - searchInUsernames = false - searchInPasswords = false - searchInUrls = true - searchByDomain = searchInfoByDomain - searchInNotes = true - searchInOTP = false - searchInOther = true - searchInUUIDs = false - searchInTags = false - searchInCurrentGroup = false - searchInSearchableGroup = true - searchInRecycleBin = false - searchInTemplates = false - }, null, max) + return mSearchHelper.createVirtualGroupWithSearchResult( + database = this, + searchParameters = searchInfo.buildSearchParameters(), + fromGroup = null, + max = max + ) } val tagPool: Tags diff --git a/database/src/main/java/com/kunzisoft/keepass/database/element/Tags.kt b/database/src/main/java/com/kunzisoft/keepass/database/element/Tags.kt index 608a2f34b..747e96fab 100644 --- a/database/src/main/java/com/kunzisoft/keepass/database/element/Tags.kt +++ b/database/src/main/java/com/kunzisoft/keepass/database/element/Tags.kt @@ -49,6 +49,10 @@ class Tags: Parcelable { } } + fun contains(tag: String): Boolean { + return mTags.contains(tag) + } + fun isEmpty(): Boolean { return mTags.isEmpty() } diff --git a/database/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt b/database/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt index d4d5b00ae..c87a8c07f 100644 --- a/database/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt +++ b/database/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt @@ -24,6 +24,7 @@ import com.kunzisoft.keepass.database.element.Entry import com.kunzisoft.keepass.database.element.Group import com.kunzisoft.keepass.database.element.node.NodeHandler import com.kunzisoft.keepass.database.element.node.NodeId +import com.kunzisoft.keepass.model.EntryInfoPasskey.FIELD_RELYING_PARTY import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_FIELD import com.kunzisoft.keepass.utils.UuidUtil import com.kunzisoft.keepass.utils.inTheSameDomainAs @@ -171,8 +172,10 @@ class SearchHelper { } if (searchParameters.searchInOther) { entry.getExtraFields().forEach { field -> - if (field.name != OTP_FIELD - || (field.name == OTP_FIELD && searchParameters.searchInOTP)) { + if ( (field.name != OTP_FIELD && field.name != FIELD_RELYING_PARTY) + || (searchParameters.searchInOTP && field.name == OTP_FIELD) + || (searchParameters.searchInRelyingParty && field.name == FIELD_RELYING_PARTY) + ) { if (checkSearchQuery(field.protectedValue.toString(), searchParameters)) return true } diff --git a/database/src/main/java/com/kunzisoft/keepass/database/search/SearchParameters.kt b/database/src/main/java/com/kunzisoft/keepass/database/search/SearchParameters.kt index 8b23a2857..2e038b8a4 100644 --- a/database/src/main/java/com/kunzisoft/keepass/database/search/SearchParameters.kt +++ b/database/src/main/java/com/kunzisoft/keepass/database/search/SearchParameters.kt @@ -37,6 +37,7 @@ class SearchParameters() : Parcelable{ var searchInExpired = false var searchInNotes = true var searchInOTP = false + var searchInRelyingParty = false var searchInOther = true var searchInUUIDs = false var searchInTags = false diff --git a/database/src/main/java/com/kunzisoft/keepass/model/EntryInfo.kt b/database/src/main/java/com/kunzisoft/keepass/model/EntryInfo.kt index 463e24841..812ba647a 100644 --- a/database/src/main/java/com/kunzisoft/keepass/model/EntryInfo.kt +++ b/database/src/main/java/com/kunzisoft/keepass/model/EntryInfo.kt @@ -22,10 +22,15 @@ package com.kunzisoft.keepass.model import android.os.Parcel import android.os.ParcelUuid import android.os.Parcelable -import com.kunzisoft.keepass.database.element.* +import com.kunzisoft.keepass.database.element.Attachment +import com.kunzisoft.keepass.database.element.Database +import com.kunzisoft.keepass.database.element.DateInstant +import com.kunzisoft.keepass.database.element.Field +import com.kunzisoft.keepass.database.element.Tags import com.kunzisoft.keepass.database.element.entry.AutoType import com.kunzisoft.keepass.database.element.security.ProtectedString import com.kunzisoft.keepass.database.element.template.TemplateField +import com.kunzisoft.keepass.model.EntryInfoPasskey.setPasskey import com.kunzisoft.keepass.otp.OtpElement import com.kunzisoft.keepass.otp.OtpEntryFields import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD @@ -33,7 +38,8 @@ import com.kunzisoft.keepass.utils.readBooleanCompat import com.kunzisoft.keepass.utils.readListCompat import com.kunzisoft.keepass.utils.readParcelableCompat import com.kunzisoft.keepass.utils.writeBooleanCompat -import java.util.* +import java.util.Locale +import java.util.UUID class EntryInfo : NodeInfo { @@ -113,6 +119,14 @@ class EntryInfo : NodeInfo { return customFields.lastOrNull { it.name == label }?.protectedValue?.toString() ?: "" } + fun addOrReplaceField(field: Field) { + customFields.lastOrNull { it.name == field.name }?.let { + it.apply { + protectedValue = field.protectedValue + } + } ?: customFields.add(field) + } + // Return true if modified private fun addUniqueField(field: Field, number: Int = 0) { var sameName = false @@ -226,6 +240,7 @@ class EntryInfo : NodeInfo { } if (database?.allowEntryCustomFields() == true) { + // TODO Move in a dedicated creditcard class val creditCard: CreditCard? = registerInfo.creditCard creditCard?.cardholder?.let { addUniqueField(Field(TemplateField.LABEL_HOLDER, ProtectedString(false, it))) @@ -240,6 +255,9 @@ class EntryInfo : NodeInfo { creditCard?.cvv?.let { addUniqueField(Field(TemplateField.LABEL_CVV, ProtectedString(true, it))) } + registerInfo.passkey?.let { + setPasskey(it) + } } } diff --git a/database/src/main/java/com/kunzisoft/keepass/model/EntryInfoPasskey.kt b/database/src/main/java/com/kunzisoft/keepass/model/EntryInfoPasskey.kt new file mode 100644 index 000000000..ae7a72914 --- /dev/null +++ b/database/src/main/java/com/kunzisoft/keepass/model/EntryInfoPasskey.kt @@ -0,0 +1,91 @@ +package com.kunzisoft.keepass.model + +import com.kunzisoft.keepass.database.element.Field +import com.kunzisoft.keepass.database.element.security.ProtectedString + +object EntryInfoPasskey { + + // field names from KeypassXC are used + private const val FIELD_USERNAME = "KPEX_PASSKEY_USERNAME" + private const val FIELD_PRIVATE_KEY = "KPEX_PASSKEY_PRIVATE_KEY_PEM" + private const val FIELD_CREDENTIAL_ID = "KPEX_PASSKEY_CREDENTIAL_ID" + private const val FIELD_USER_HANDLE = "KPEX_PASSKEY_USER_HANDLE" + const val FIELD_RELYING_PARTY = "KPEX_PASSKEY_RELYING_PARTY" + + private const val PASSKEY_TAG = "Passkey" + + fun EntryInfo.getPasskey(): Passkey? { + if (this.tags.toList().contains(PASSKEY_TAG).not()) { + return null + } + var username = "" + var privateKeyPem = "" + var credId = "" + var userHandle = "" + var relyingParty = "" + for (field in this.customFields) { + when (field.name) { + FIELD_USERNAME -> { + username = field.protectedValue.stringValue + } + FIELD_PRIVATE_KEY -> { + privateKeyPem = field.protectedValue.stringValue + } + FIELD_CREDENTIAL_ID -> { + credId = field.protectedValue.stringValue + } + FIELD_USER_HANDLE -> { + userHandle = field.protectedValue.stringValue + } + FIELD_RELYING_PARTY -> { + relyingParty = field.protectedValue.stringValue + } + } + } + return Passkey( + username = username, + displayName = this.getVisualTitle(), + privateKeyPem = privateKeyPem, + credentialId = credId, + userHandle = userHandle, + relyingParty = relyingParty + ) + } + + fun EntryInfo.setPasskey(passkey: Passkey) { + tags.put(PASSKEY_TAG) + title = passkey.displayName + username = passkey.username + url = passkey.relyingParty + addOrReplaceField( + Field( + FIELD_USERNAME, + ProtectedString(enableProtection = false, passkey.username) + ) + ) + addOrReplaceField( + Field( + FIELD_PRIVATE_KEY, + ProtectedString(enableProtection = true, passkey.privateKeyPem) + ) + ) + addOrReplaceField( + Field( + FIELD_CREDENTIAL_ID, + ProtectedString(enableProtection = true, passkey.credentialId) + ) + ) + addOrReplaceField( + Field( + FIELD_USER_HANDLE, + ProtectedString(enableProtection = true, passkey.userHandle) + ) + ) + addOrReplaceField( + Field( + FIELD_RELYING_PARTY, + ProtectedString(enableProtection = false, passkey.relyingParty) + ) + ) + } +} \ No newline at end of file diff --git a/database/src/main/java/com/kunzisoft/keepass/model/Passkey.kt b/database/src/main/java/com/kunzisoft/keepass/model/Passkey.kt new file mode 100644 index 000000000..9f5b6a938 --- /dev/null +++ b/database/src/main/java/com/kunzisoft/keepass/model/Passkey.kt @@ -0,0 +1,14 @@ +package com.kunzisoft.keepass.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Passkey( + val username: String, + val displayName: String, + val privateKeyPem: String, + val credentialId: String, + val userHandle: String, + val relyingParty: String, +): Parcelable diff --git a/database/src/main/java/com/kunzisoft/keepass/model/RegisterInfo.kt b/database/src/main/java/com/kunzisoft/keepass/model/RegisterInfo.kt index cc0e3ce94..2c08e2a78 100644 --- a/database/src/main/java/com/kunzisoft/keepass/model/RegisterInfo.kt +++ b/database/src/main/java/com/kunzisoft/keepass/model/RegisterInfo.kt @@ -4,15 +4,19 @@ import android.os.Parcel import android.os.Parcelable import com.kunzisoft.keepass.utils.readParcelableCompat -data class RegisterInfo(val searchInfo: SearchInfo, - val username: String?, - val password: String?, - val creditCard: CreditCard?): Parcelable { +data class RegisterInfo( + val searchInfo: SearchInfo, + val username: String?, + val password: String? = null, + val creditCard: CreditCard? = null, + val passkey: Passkey? = null +): Parcelable { constructor(parcel: Parcel) : this( parcel.readParcelableCompat() ?: SearchInfo(), parcel.readString() ?: "", parcel.readString() ?: "", + parcel.readParcelableCompat(), parcel.readParcelableCompat()) { } @@ -21,6 +25,7 @@ data class RegisterInfo(val searchInfo: SearchInfo, parcel.writeString(username) parcel.writeString(password) parcel.writeParcelable(creditCard, flags) + parcel.writeParcelable(passkey, flags) } override fun describeContents(): Int { diff --git a/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt b/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt index e4845e054..094a6977c 100644 --- a/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt +++ b/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt @@ -4,6 +4,7 @@ import android.content.res.Resources import android.net.Uri import android.os.Parcel import android.os.Parcelable +import com.kunzisoft.keepass.database.search.SearchParameters import com.kunzisoft.keepass.otp.OtpEntryFields import com.kunzisoft.keepass.utils.ObjectNameResource import com.kunzisoft.keepass.utils.readBooleanCompat @@ -33,6 +34,7 @@ class SearchInfo : ObjectNameResource, Parcelable { get() { return if (webDomain == null) null else field } + var relyingParty: String? = null var otpString: String? = null constructor() @@ -42,6 +44,7 @@ class SearchInfo : ObjectNameResource, Parcelable { applicationId = toCopy?.applicationId webDomain = toCopy?.webDomain webScheme = toCopy?.webScheme + relyingParty = toCopy?.relyingParty otpString = toCopy?.otpString } @@ -53,6 +56,8 @@ class SearchInfo : ObjectNameResource, Parcelable { webDomain = if (readDomain.isNullOrEmpty()) null else readDomain val readScheme = parcel.readString() webScheme = if (readScheme.isNullOrEmpty()) null else readScheme + val readRelyingParty = parcel.readString() + relyingParty = if (readRelyingParty.isNullOrEmpty()) null else readRelyingParty val readOtp = parcel.readString() otpString = if (readOtp.isNullOrEmpty()) null else readOtp } @@ -66,6 +71,7 @@ class SearchInfo : ObjectNameResource, Parcelable { parcel.writeString(applicationId ?: "") parcel.writeString(webDomain ?: "") parcel.writeString(webScheme ?: "") + parcel.writeString(relyingParty ?: "") parcel.writeString(otpString ?: "") } @@ -82,12 +88,55 @@ class SearchInfo : ObjectNameResource, Parcelable { return applicationId == null && webDomain == null && webScheme == null + && relyingParty == null && otpString == null } - fun isASearchByDomain(): Boolean { + private fun isASearchByDomain(): Boolean { return toString() == webDomain && webDomain != null - } + } + + private fun isAPasskeySearch(): Boolean { + return toString() == relyingParty && relyingParty != null + } + + fun buildSearchParameters(): SearchParameters { + return SearchParameters().apply { + if (isAPasskeySearch()) { + searchQuery = toString() + searchInTitles = false + searchInUsernames = false + searchInPasswords = false + searchInUrls = false + searchInNotes = false + searchInOTP = false + searchInOther = false + searchInUUIDs = false + searchInTags = false + searchInRelyingParty = true + searchInCurrentGroup = false + searchInSearchableGroup = false + searchInRecycleBin = false + searchInTemplates = false + } else { + searchQuery = toString() + searchInTitles = true + searchInUsernames = false + searchInPasswords = false + searchInUrls = true + searchByDomain = isASearchByDomain() + searchInNotes = true + searchInOTP = false + searchInOther = true + searchInUUIDs = false + searchInTags = false + searchInCurrentGroup = false + searchInSearchableGroup = true + searchInRecycleBin = false + searchInTemplates = false + } + } + } override fun equals(other: Any?): Boolean { if (this === other) return true @@ -97,6 +146,7 @@ class SearchInfo : ObjectNameResource, Parcelable { if (applicationId != other.applicationId) return false if (webDomain != other.webDomain) return false if (webScheme != other.webScheme) return false + if (relyingParty != other.relyingParty) return false if (otpString != other.otpString) return false return true @@ -107,12 +157,13 @@ class SearchInfo : ObjectNameResource, Parcelable { result = 31 * result + (applicationId?.hashCode() ?: 0) result = 31 * result + (webDomain?.hashCode() ?: 0) result = 31 * result + (webScheme?.hashCode() ?: 0) + result = 31 * result + (relyingParty?.hashCode() ?: 0) result = 31 * result + (otpString?.hashCode() ?: 0) return result } override fun toString(): String { - return otpString ?: webDomain ?: applicationId ?: "" + return otpString ?: webDomain ?: applicationId ?: relyingParty ?: "" } companion object { From 3fbdf78ba1a080237a0b42f3b7c43fa00bd44478 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Wed, 16 Jul 2025 15:30:21 +0200 Subject: [PATCH 007/136] fix: Search Info --- .../activities/legacy/DatabaseModeActivity.kt | 5 +-- .../EntrySelectionHelper.kt | 5 +++ .../activity/PasskeyLauncherActivity.kt | 35 +++++++++++-------- .../passkey/PasskeyProviderService.kt | 21 ++++++++++- .../keepass/database/search/SearchHelper.kt | 29 ++++++++++----- .../database/search/SearchParameters.kt | 2 ++ .../com/kunzisoft/keepass/model/SearchInfo.kt | 15 ++++++-- 7 files changed, 80 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseModeActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseModeActivity.kt index 84397ab7b..c7832033b 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseModeActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/legacy/DatabaseModeActivity.kt @@ -48,10 +48,7 @@ abstract class DatabaseModeActivity : DatabaseActivity() { } fun onLaunchActivitySpecialMode() { - // TODO Verify behavior for Autofill Callback #765 - if (isIntentSender()) { - onValidateSpecialMode() - } else { + if (!isIntentSender()) { EntrySelectionHelper.removeModesFromIntent(intent) EntrySelectionHelper.removeInfoFromIntent(intent) finish() diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt index 1be32b30a..6a78e115e 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt @@ -199,8 +199,13 @@ object EntrySelectionHelper { } private fun addTypeModeInIntent(intent: Intent, typeMode: TypeMode) { + // TODO Replace by Intent.addTypeMode intent.putEnumExtra(KEY_TYPE_MODE, typeMode) } + fun Intent.addTypeMode(typeMode: TypeMode): Intent { + this.putEnumExtra(KEY_TYPE_MODE, typeMode) + return this + } fun retrieveTypeModeFromIntent(intent: Intent): TypeMode { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt index de433806d..365388689 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt @@ -24,6 +24,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build +import android.os.Bundle import android.util.Log import androidx.activity.result.ActivityResultLauncher import androidx.annotation.RequiresApi @@ -33,8 +34,8 @@ import androidx.credentials.provider.PendingIntentHandler import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity import com.kunzisoft.keepass.activities.GroupActivity import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity -import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addSpecialMode +import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addTypeMode import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher import com.kunzisoft.keepass.credentialprovider.SpecialMode import com.kunzisoft.keepass.credentialprovider.TypeMode @@ -66,6 +67,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { private var mUsageParameters: PublicKeyCredentialUsageParameters? = null private var mCreationParameters: PublicKeyCredentialCreationParameters? = null private var mPasskey: Passkey? = null + private var mSearchInfo: SearchInfo = SearchInfo() private var mPasskeySelectionActivityResultLauncher: ActivityResultLauncher? = this.buildActivityResultLauncher( @@ -131,24 +133,26 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { return false } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + mSearchInfo = intent.retrieveSearchInfo() ?: mSearchInfo + } + override fun onDatabaseRetrieved(database: ContextualDatabase?) { super.onDatabaseRetrieved(database) // TODO nodeId Really useful ? checkSecurity(intent, nodeId) - EntrySelectionHelper.retrieveSpecialModeFromIntent(intent).let { specialMode -> - val searchInfo = intent.retrieveSearchInfo() ?: SearchInfo() - when (specialMode) { - SpecialMode.SELECTION -> { - launchSelection(database, searchInfo) - } - SpecialMode.REGISTRATION -> { - launchRegistration(database, searchInfo) - } - else -> { - Log.e(TAG, "Passkey launch mode not supported") - setResult(Activity.RESULT_CANCELED) - finish() - } + when (mSpecialMode) { + SpecialMode.SELECTION -> { + launchSelection(database, mSearchInfo) + } + SpecialMode.REGISTRATION -> { + launchRegistration(database, mSearchInfo) + } + else -> { + Log.e(TAG, "Passkey launch mode not supported") + setResult(Activity.RESULT_CANCELED) + finish() } } } @@ -336,6 +340,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { Math.random().toInt(), Intent(context, PasskeyLauncherActivity::class.java).apply { addSpecialMode(specialMode) + addTypeMode(TypeMode.PASSKEY) searchInfo?.let { putExtra(EXTRA_SEARCH_INFO, searchInfo) } diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt index f9740e9dd..034d366ab 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt @@ -1,3 +1,22 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ package com.kunzisoft.keepass.credentialprovider.passkey import android.graphics.BlendMode @@ -128,7 +147,7 @@ class PasskeyProviderService : CredentialProviderService() { pendingIntent = usagePendingIntent, beginGetPublicKeyCredentialOption = option, displayName = passkey?.displayName, - isAutoSelectAllowed = false + isAutoSelectAllowed = true ) ) } diff --git a/database/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt b/database/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt index c87a8c07f..3fa4cad21 100644 --- a/database/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt +++ b/database/src/main/java/com/kunzisoft/keepass/database/search/SearchHelper.kt @@ -170,16 +170,27 @@ class SearchHelper { if (checkSearchQuery(hexString, searchParameters)) return true } + if (searchParameters.searchInOTP) { + if(entry.getExtraFields().any { field -> + field.name == OTP_FIELD + && checkSearchQuery(field.protectedValue.stringValue, searchParameters) + }) + return true + } + if (searchParameters.searchInRelyingParty) { + if(entry.getExtraFields().any { field -> + field.name == FIELD_RELYING_PARTY + && checkSearchQuery(field.protectedValue.stringValue, searchParameters) + }) + return true + } if (searchParameters.searchInOther) { - entry.getExtraFields().forEach { field -> - if ( (field.name != OTP_FIELD && field.name != FIELD_RELYING_PARTY) - || (searchParameters.searchInOTP && field.name == OTP_FIELD) - || (searchParameters.searchInRelyingParty && field.name == FIELD_RELYING_PARTY) - ) { - if (checkSearchQuery(field.protectedValue.toString(), searchParameters)) - return true - } - } + if(entry.getExtraFields().any { field -> + field.name != OTP_FIELD + && field.name != FIELD_RELYING_PARTY + && checkSearchQuery(field.protectedValue.toString(), searchParameters) + }) + return true } if (searchParameters.searchInTags) { if (checkSearchQuery(entry.tags.toString(), searchParameters)) diff --git a/database/src/main/java/com/kunzisoft/keepass/database/search/SearchParameters.kt b/database/src/main/java/com/kunzisoft/keepass/database/search/SearchParameters.kt index 2e038b8a4..b913d2585 100644 --- a/database/src/main/java/com/kunzisoft/keepass/database/search/SearchParameters.kt +++ b/database/src/main/java/com/kunzisoft/keepass/database/search/SearchParameters.kt @@ -59,6 +59,7 @@ class SearchParameters() : Parcelable{ searchInExpired = parcel.readByte() != 0.toByte() searchInNotes = parcel.readByte() != 0.toByte() searchInOTP = parcel.readByte() != 0.toByte() + searchInRelyingParty = parcel.readByte() != 0.toByte() searchInOther = parcel.readByte() != 0.toByte() searchInUUIDs = parcel.readByte() != 0.toByte() searchInTags = parcel.readByte() != 0.toByte() @@ -79,6 +80,7 @@ class SearchParameters() : Parcelable{ parcel.writeByte(if (searchInExpired) 1 else 0) parcel.writeByte(if (searchInNotes) 1 else 0) parcel.writeByte(if (searchInOTP) 1 else 0) + parcel.writeByte(if (searchInRelyingParty) 1 else 0) parcel.writeByte(if (searchInOther) 1 else 0) parcel.writeByte(if (searchInUUIDs) 1 else 0) parcel.writeByte(if (searchInTags) 1 else 0) diff --git a/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt b/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt index 094a6977c..827c16006 100644 --- a/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt +++ b/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt @@ -12,6 +12,7 @@ import com.kunzisoft.keepass.utils.writeBooleanCompat class SearchInfo : ObjectNameResource, Parcelable { var manualSelection: Boolean = false + var tag: String? = null var applicationId: String? = null set(value) { field = when { @@ -41,6 +42,7 @@ class SearchInfo : ObjectNameResource, Parcelable { constructor(toCopy: SearchInfo?) { manualSelection = toCopy?.manualSelection ?: manualSelection + tag = toCopy?.tag applicationId = toCopy?.applicationId webDomain = toCopy?.webDomain webScheme = toCopy?.webScheme @@ -50,6 +52,8 @@ class SearchInfo : ObjectNameResource, Parcelable { private constructor(parcel: Parcel) { manualSelection = parcel.readBooleanCompat() + val readTag = parcel.readString() + tag = if (readTag.isNullOrEmpty()) null else readTag val readAppId = parcel.readString() applicationId = if (readAppId.isNullOrEmpty()) null else readAppId val readDomain = parcel.readString() @@ -68,6 +72,7 @@ class SearchInfo : ObjectNameResource, Parcelable { override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeBooleanCompat(manualSelection) + parcel.writeString(tag ?: "") parcel.writeString(applicationId ?: "") parcel.writeString(webDomain ?: "") parcel.writeString(webScheme ?: "") @@ -85,7 +90,8 @@ class SearchInfo : ObjectNameResource, Parcelable { } fun containsOnlyNullValues(): Boolean { - return applicationId == null + return tag == null + && applicationId == null && webDomain == null && webScheme == null && relyingParty == null @@ -103,11 +109,12 @@ class SearchInfo : ObjectNameResource, Parcelable { fun buildSearchParameters(): SearchParameters { return SearchParameters().apply { if (isAPasskeySearch()) { - searchQuery = toString() + searchQuery = relyingParty!! searchInTitles = false searchInUsernames = false searchInPasswords = false searchInUrls = false + searchByDomain = false searchInNotes = false searchInOTP = false searchInOther = false @@ -143,6 +150,7 @@ class SearchInfo : ObjectNameResource, Parcelable { if (other !is SearchInfo) return false if (manualSelection != other.manualSelection) return false + if (tag != other.tag) return false if (applicationId != other.applicationId) return false if (webDomain != other.webDomain) return false if (webScheme != other.webScheme) return false @@ -154,6 +162,7 @@ class SearchInfo : ObjectNameResource, Parcelable { override fun hashCode(): Int { var result = manualSelection.hashCode() + result = 31 * result + (tag?.hashCode() ?: 0) result = 31 * result + (applicationId?.hashCode() ?: 0) result = 31 * result + (webDomain?.hashCode() ?: 0) result = 31 * result + (webScheme?.hashCode() ?: 0) @@ -163,7 +172,7 @@ class SearchInfo : ObjectNameResource, Parcelable { } override fun toString(): String { - return otpString ?: webDomain ?: applicationId ?: relyingParty ?: "" + return otpString ?: webDomain ?: applicationId ?: relyingParty ?: tag ?: "" } companion object { From 91781f36ac712febecc39188b22415c54c26e493 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Wed, 16 Jul 2025 16:15:55 +0200 Subject: [PATCH 008/136] fix: Registration callback --- .../keepass/credentialprovider/EntrySelectionHelper.kt | 2 +- .../credentialprovider/activity/PasskeyLauncherActivity.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt index 6a78e115e..6625420f4 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/EntrySelectionHelper.kt @@ -152,7 +152,7 @@ object EntrySelectionHelper { if (activityResultLauncher == null) { intent.flags = intent.flags or Intent.FLAG_ACTIVITY_CLEAR_TASK } - context?.startActivity(intent) ?: activityResultLauncher?.launch(intent) ?: + activityResultLauncher?.launch(intent) ?: context?.startActivity(intent) ?: throw IllegalStateException("At least Context or ActivityResultLauncher must not be null") } diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt index 365388689..f27b44782 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt @@ -282,7 +282,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { searchInfo = searchInfo, onItemsFound = { openedDatabase, _ -> Log.w(TAG, "Passkey found for registration, " + - "but launch manual registration for overwrite") + "but launch manual registration for a new entry") GroupActivity.launchForRegistration( context = this, activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, From c6b01947b3f7ef45be8685256d905805f0d75649 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Tue, 22 Jul 2025 13:45:44 +0200 Subject: [PATCH 009/136] fix: Multiple request --- .../credentialprovider/activity/PasskeyLauncherActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt index f27b44782..509cece30 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt @@ -346,7 +346,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { } addAuthCode(passkeyEntryNodeId) }, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) } } From 4a1cee619c240f2e6654c41a1d091f7609362e49 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Sat, 16 Aug 2025 19:57:50 +0200 Subject: [PATCH 010/136] fix: Refactiring JSON objects --- .../activity/PasskeyLauncherActivity.kt | 118 ++++----- .../passkey/PasskeyProviderService.kt | 19 +- .../data/AuthenticatorAssertionResponse.kt | 66 +++++ .../data/AuthenticatorAttestationResponse.kt | 86 ++++++ .../passkey/data/AuthenticatorData.kt | 57 ++++ .../passkey/data/AuthenticatorResponse.kt | 28 ++ .../credentialprovider/passkey/data/Cbor.kt | 208 +++++++++++++++ .../passkey/data/ClientDataDefinedResponse.kt | 37 +++ .../data/ClientDataNotDefinedResponse.kt | 67 +++++ .../passkey/data/ClientDataResponse.kt | 25 ++ .../passkey/data/FidoDataTypes.kt | 40 +++ .../passkey/data/FidoPublicKeyCredential.kt | 50 ++++ .../PublicKeyCredentialCreationOptions.kt | 77 +++++- .../PublicKeyCredentialCreationParameters.kt | 24 +- .../data/PublicKeyCredentialRequestOptions.kt | 33 ++- .../PublicKeyCredentialUsageParameters.kt | 26 +- .../passkey/util/Base64Helper.kt | 32 ++- .../passkey/util/JsonHelper.kt | 249 ------------------ .../passkey/util/OriginHelper.kt | 21 -- .../passkey/util/OriginManager.kt | 80 ++++++ .../passkey/util/PasskeyHelper.kt | 246 +++++++---------- .../com/kunzisoft/asymmetric/Signature.kt | 4 - .../com/kunzisoft/random/KeePassDXRandom.kt | 21 +- 23 files changed, 1094 insertions(+), 520 deletions(-) create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAssertionResponse.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAttestationResponse.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorData.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorResponse.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/Cbor.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataDefinedResponse.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataNotDefinedResponse.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataResponse.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoDataTypes.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoPublicKeyCredential.kt delete mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/JsonHelper.kt delete mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginHelper.kt create mode 100644 app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginManager.kt diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt index 509cece30..e408607e0 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt @@ -41,14 +41,12 @@ import com.kunzisoft.keepass.credentialprovider.SpecialMode import com.kunzisoft.keepass.credentialprovider.TypeMode import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters -import com.kunzisoft.keepass.credentialprovider.passkey.util.OriginHelper import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addAuthCode import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildCreatePublicKeyCredentialResponse import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyPublicKeyCredential import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removePasskey import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveNodeId import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskey -import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyCreationComponent import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyCreationRequestParameters import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyUsageRequestParameters import com.kunzisoft.keepass.database.ContextualDatabase @@ -115,7 +113,6 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { PendingIntentHandler.setCreateCredentialResponse( intent = responseIntent, response = buildCreatePublicKeyCredentialResponse( - packageName = packageName, publicKeyCredentialCreationParameters = it ) ) @@ -183,7 +180,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { finish() } ?: run { Log.e(TAG, "Unable to auto select passkey, usage parameters are empty") - setResult(Activity.RESULT_CANCELED) + setResult(RESULT_CANCELED) finish() } } @@ -244,7 +241,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { finish() } ?: run { Log.e(TAG, "Unable to auto select passkey, usage parameters are empty") - setResult(Activity.RESULT_CANCELED) + setResult(RESULT_CANCELED) finish() } } @@ -254,67 +251,62 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { searchInfo: SearchInfo ) { Log.d(TAG, "Launch passkey registration") - PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent)?.callingAppInfo?.let { callingAppInfo -> - retrievePasskeyCreationRequestParameters( - creationOptions = intent.retrievePasskeyCreationComponent(), - webOrigin = OriginHelper.getWebOrigin(callingAppInfo, assets), - apkSigningCertificate = - callingAppInfo - .signingInfo.apkContentsSigners - .getOrNull(0)?.toByteArray(), - passkeyCreated = { passkey, publicKeyCredentialParameters -> - // Save the requested parameters - mPasskey = passkey - mCreationParameters = publicKeyCredentialParameters - // Manage the passkey and create a register info - val registerInfo = RegisterInfo( + retrievePasskeyCreationRequestParameters( + intent = intent, + assetManager = assets, + packageName = packageName, + passkeyCreated = { passkey, publicKeyCredentialParameters -> + // Save the requested parameters + mPasskey = passkey + mCreationParameters = publicKeyCredentialParameters + // Manage the passkey and create a register info + val registerInfo = RegisterInfo( + searchInfo = searchInfo, + username = null, + passkey = passkey + ) + // If nodeId already provided + intent.retrieveNodeId()?.let { nodeId -> + autoRegisterPasskeyAndSetResult(database, nodeId) + } ?: run { + SearchHelper.checkAutoSearchInfo( + context = this, + database = database, searchInfo = searchInfo, - username = null, - passkey = passkey + onItemsFound = { openedDatabase, _ -> + Log.w(TAG, "Passkey found for registration, " + + "but launch manual registration for a new entry") + GroupActivity.launchForRegistration( + context = this, + activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + }, + onItemNotFound = { openedDatabase -> + Log.d(TAG, "Launch new manual registration in opened database") + GroupActivity.launchForRegistration( + context = this, + activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, + database = openedDatabase, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + }, + onDatabaseClosed = { + Log.d(TAG, "Manual passkey registration in closed database") + FileDatabaseSelectActivity.launchForRegistration( + context = this, + activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, + registerInfo = registerInfo, + typeMode = TypeMode.PASSKEY + ) + } ) - // If nodeId already provided - intent.retrieveNodeId()?.let { nodeId -> - autoRegisterPasskeyAndSetResult(database, nodeId) - } ?: run { - SearchHelper.checkAutoSearchInfo( - context = this, - database = database, - searchInfo = searchInfo, - onItemsFound = { openedDatabase, _ -> - Log.w(TAG, "Passkey found for registration, " + - "but launch manual registration for a new entry") - GroupActivity.launchForRegistration( - context = this, - activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, - database = openedDatabase, - registerInfo = registerInfo, - typeMode = TypeMode.PASSKEY - ) - }, - onItemNotFound = { openedDatabase -> - Log.d(TAG, "Launch new manual registration in opened database") - GroupActivity.launchForRegistration( - context = this, - activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, - database = openedDatabase, - registerInfo = registerInfo, - typeMode = TypeMode.PASSKEY - ) - }, - onDatabaseClosed = { - Log.d(TAG, "Manual passkey registration in closed database") - FileDatabaseSelectActivity.launchForRegistration( - context = this, - activityResultLauncher = mPasskeyRegistrationActivityResultLauncher, - registerInfo = registerInfo, - typeMode = TypeMode.PASSKEY - ) - } - ) - } } - ) - } + } + ) } companion object { diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt index 034d366ab..96312864c 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt @@ -46,7 +46,8 @@ import com.kunzisoft.keepass.R import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildIcon import com.kunzisoft.keepass.credentialprovider.SpecialMode import com.kunzisoft.keepass.credentialprovider.activity.PasskeyLauncherActivity -import com.kunzisoft.keepass.credentialprovider.passkey.util.JsonHelper +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationOptions +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRequestOptions import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.DatabaseTaskProvider import com.kunzisoft.keepass.database.helper.SearchHelper @@ -117,13 +118,11 @@ class PasskeyProviderService : CredentialProviderService() { val passkeyEntries: MutableList = mutableListOf() - val relyingPartyJson = JsonHelper - .parseJsonToRequestOptions(option.requestJson) - .relyingParty + val relyingPartyId = PublicKeyCredentialRequestOptions(option.requestJson).rpId val searchInfo = SearchInfo().apply { - relyingParty = relyingPartyJson + relyingParty = relyingPartyId } - Log.d(TAG, "Build passkey search for relying party $relyingPartyJson") + Log.d(TAG, "Build passkey search for relying party $relyingPartyId") SearchHelper.checkAutoSearchInfo( context = this, database = mDatabase, @@ -154,7 +153,7 @@ class PasskeyProviderService : CredentialProviderService() { } }, onItemNotFound = { _ -> - Log.w(TAG, "No passkey found in the database with this relying party : $relyingPartyJson") + Log.w(TAG, "No passkey found in the database with this relying party : $relyingPartyId") Log.d(TAG, "Add pending intent for passkey selection in opened database") PasskeyLauncherActivity.getPendingIntent( context = applicationContext, @@ -252,11 +251,11 @@ class PasskeyProviderService : CredentialProviderService() { val accountName = mDatabase?.name ?: getString(R.string.passkey_locked_database_username) val createEntries: MutableList = mutableListOf() + val relyingPartyId = PublicKeyCredentialCreationOptions(request.requestJson).relyingPartyEntity.id val searchInfo = SearchInfo().apply { - relyingParty = JsonHelper - .parseJsonToCreateOptions(request.requestJson) - .relyingParty + relyingParty = relyingPartyId } + Log.d(TAG, "Build passkey search for relying party $relyingPartyId") SearchHelper.checkAutoSearchInfo( context = this, database = mDatabase, diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAssertionResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAssertionResponse.kt new file mode 100644 index 000000000..7ea51bced --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAssertionResponse.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import androidx.credentials.exceptions.GetCredentialUnknownException +import com.kunzisoft.asymmetric.Signature +import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper.Companion.b64Encode +import org.json.JSONObject + +class AuthenticatorAssertionResponse( + private val requestOptions: PublicKeyCredentialRequestOptions, + private val userPresent: Boolean, + private val userVerified: Boolean, + private val backupEligibility: Boolean, + private val backupState: Boolean, + private var userHandle: String, + privateKey: String, + private val clientDataResponse: ClientDataResponse, +) : AuthenticatorResponse { + + override var clientJson = JSONObject() + private var authenticatorData: ByteArray = AuthenticatorData.buildAuthenticatorData( + relyingPartyId = requestOptions.rpId.toByteArray(), + userPresent = userPresent, + userVerified = userVerified, + backupEligibility = backupEligibility, + backupState = backupState + ) + private var signature: ByteArray = byteArrayOf() + + init { + signature = Signature.sign(privateKey, dataToSign()) + ?: throw GetCredentialUnknownException("signing failed") + } + + private fun dataToSign(): ByteArray { + return authenticatorData + clientDataResponse.hashData() + } + + override fun json(): JSONObject { + // https://www.w3.org/TR/webauthn-3/#authdata-flags + return clientJson.apply { + put("clientDataJSON", clientDataResponse.buildResponse()) + put("authenticatorData", b64Encode(authenticatorData)) + put("signature", b64Encode(signature)) + put("userHandle", userHandle) + } + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAttestationResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAttestationResponse.kt new file mode 100644 index 000000000..d44a0c116 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorAttestationResponse.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper.Companion.b64Encode +import org.json.JSONArray +import org.json.JSONObject + +class AuthenticatorAttestationResponse( + private val requestOptions: PublicKeyCredentialCreationOptions, + private val credentialId: ByteArray, + private val credentialPublicKey: ByteArray, + private val userPresent: Boolean, + private val userVerified: Boolean, + private val backupEligibility: Boolean, + private val backupState: Boolean, + private val publicKeyTypeId: Long, + private val publicKeyCbor: ByteArray, + private val clientDataResponse: ClientDataResponse, +) : AuthenticatorResponse { + + override var clientJson = JSONObject() + var attestationObject: ByteArray + + init { + attestationObject = defaultAttestationObject() + } + + private fun buildAuthData(): ByteArray { + return AuthenticatorData.buildAuthenticatorData( + relyingPartyId = requestOptions.relyingPartyEntity.id.toByteArray(), + userPresent = userPresent, + userVerified = userVerified, + backupEligibility = backupEligibility, + backupState = backupState, + attestedCredentialData = true + ) + AAGUID + + //credIdLen + byteArrayOf((credentialId.size shr 8).toByte(), credentialId.size.toByte()) + + credentialId + + credentialPublicKey + } + + internal fun defaultAttestationObject(): ByteArray { + val ao = mutableMapOf() + ao.put("fmt", "none") + ao.put("attStmt", emptyMap()) + ao.put("authData", buildAuthData()) + return Cbor().encode(ao) + } + + override fun json(): JSONObject { + // See AuthenticatorAttestationResponseJSON at + // https://w3c.github.io/webauthn/#ref-for-dom-publickeycredential-tojson + return clientJson.apply { + put("clientDataJSON", clientDataResponse.buildResponse()) + put("authenticatorData", b64Encode(buildAuthData())) + put("transports", JSONArray(listOf("internal", "hybrid"))) + put("publicKey", b64Encode(publicKeyCbor)) + put("publicKeyAlgorithm", publicKeyTypeId) + put("attestationObject", b64Encode(attestationObject)) + } + } + + companion object { + // TODO Authenticator Attestation Global Unique Identifier + private val AAGUID = ByteArray(16) { 0 } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorData.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorData.kt new file mode 100644 index 000000000..6df2b10be --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorData.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import com.kunzisoft.encrypt.HashManager + +class AuthenticatorData { + + companion object { + fun buildAuthenticatorData( + relyingPartyId: ByteArray, + userPresent: Boolean, + userVerified: Boolean, + backupEligibility: Boolean, + backupState: Boolean, + attestedCredentialData: Boolean = false + ): ByteArray { + // https://www.w3.org/TR/webauthn-3/#table-authData + var flags = 0 + if (userPresent) + flags = flags or 0x01 + // bit at index 1 is reserved + if (userVerified) + flags = flags or 0x04 + if (backupEligibility) + flags = flags or 0x08 + if (backupState) + flags = flags or 0x10 + // bit at index 5 is reserved + if (attestedCredentialData) { + flags = flags or 0x40 + } + // bit at index 7: Extension data included == false + + return HashManager.hashSha256(relyingPartyId) + + byteArrayOf(flags.toByte()) + + byteArrayOf(0, 0, 0, 0) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorResponse.kt new file mode 100644 index 000000000..57d9ab2dc --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/AuthenticatorResponse.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import org.json.JSONObject + +interface AuthenticatorResponse { + var clientJson: JSONObject + + fun json(): JSONObject +} diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/Cbor.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/Cbor.kt new file mode 100644 index 000000000..8dbbb8657 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/Cbor.kt @@ -0,0 +1,208 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import androidx.annotation.RestrictTo + +@RestrictTo(RestrictTo.Scope.LIBRARY) +class Cbor { + data class Item(val item: Any, val len: Int) + + data class Arg(val arg: Long, val len: Int) + + val TYPE_UNSIGNED_INT = 0x00 + val TYPE_NEGATIVE_INT = 0x01 + val TYPE_BYTE_STRING = 0x02 + val TYPE_TEXT_STRING = 0x03 + val TYPE_ARRAY = 0x04 + val TYPE_MAP = 0x05 + val TYPE_TAG = 0x06 + val TYPE_FLOAT = 0x07 + + fun decode(data: ByteArray): Any { + val ret = parseItem(data, 0) + return ret.item + } + + fun encode(data: Any): ByteArray { + if (data is Number) { + if (data is Double) { + throw IllegalArgumentException("Don't support doubles yet") + } else { + val value = data.toLong() + if (value >= 0) { + return createArg(TYPE_UNSIGNED_INT, value) + } else { + return createArg(TYPE_NEGATIVE_INT, -1 - value) + } + } + } + if (data is ByteArray) { + return createArg(TYPE_BYTE_STRING, data.size.toLong()) + data + } + if (data is String) { + return createArg(TYPE_TEXT_STRING, data.length.toLong()) + data.encodeToByteArray() + } + if (data is List<*>) { + var ret = createArg(TYPE_ARRAY, data.size.toLong()) + for (i in data) { + ret += encode(i!!) + } + return ret + } + if (data is Map<*, *>) { + // See: + // https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html#ctap2-canonical-cbor-encoding-form + var ret = createArg(TYPE_MAP, data.size.toLong()) + var byteMap: MutableMap = mutableMapOf() + for (i in data) { + // Convert to byte arrays so we can sort them. + byteMap.put(encode(i.key!!), encode(i.value!!)) + } + + var keysList = ArrayList(byteMap.keys) + keysList.sortedWith( + Comparator { a, b -> + // If two keys have different lengths, the shorter one sorts earlier; + // If two keys have the same length, the one with the lower value in (byte-wise) + // lexical order sorts earlier. + var aBytes = byteMap.get(a)!! + var bBytes = byteMap.get(b)!! + when { + a.size > b.size -> 1 + a.size < b.size -> -1 + aBytes.size > bBytes.size -> 1 + aBytes.size < bBytes.size -> -1 + else -> 0 + } + } + ) + + for (key in keysList) { + ret += key + ret += byteMap.get(key)!! + } + return ret + } + throw IllegalArgumentException("Bad type") + } + + private fun getType(data: ByteArray, offset: Int): Int { + val d = data[offset].toInt() + return (d and 0xFF) shr 5 + } + + private fun getArg(data: ByteArray, offset: Int): Arg { + val arg = data[offset].toLong() and 0x1F + if (arg < 24) { + return Arg(arg, 1) + } + if (arg == 24L) { + return Arg(data[offset + 1].toLong() and 0xFF, 2) + } + if (arg == 25L) { + var ret = (data[offset + 1].toLong() and 0xFF) shl 8 + ret = ret or (data[offset + 2].toLong() and 0xFF) + return Arg(ret, 3) + } + if (arg == 26L) { + var ret = (data[offset + 1].toLong() and 0xFF) shl 24 + ret = ret or ((data[offset + 2].toLong() and 0xFF) shl 16) + ret = ret or ((data[offset + 3].toLong() and 0xFF) shl 8) + ret = ret or (data[offset + 4].toLong() and 0xFF) + return Arg(ret, 5) + } + throw IllegalArgumentException("Bad arg") + } + + private fun parseItem(data: ByteArray, offset: Int): Item { + val itemType = getType(data, offset) + val arg = getArg(data, offset) + println("Type $itemType ${arg.arg} ${arg.len}") + + when (itemType) { + TYPE_UNSIGNED_INT -> { + return Item(arg.arg, arg.len) + } + TYPE_NEGATIVE_INT -> { + return Item(-1 - arg.arg, arg.len) + } + TYPE_BYTE_STRING -> { + val ret = + data.sliceArray(offset + arg.len.toInt() until offset + arg.len.toInt() + arg.arg.toInt()) + return Item(ret, arg.len + arg.arg.toInt()) + } + TYPE_TEXT_STRING -> { + val ret = + data.sliceArray(offset + arg.len.toInt() until offset + arg.len.toInt() + arg.arg.toInt()) + return Item(ret.toString(Charsets.UTF_8), arg.len + arg.arg.toInt()) + } + TYPE_ARRAY -> { + val ret = mutableListOf() + var consumed = arg.len + for (i in 0 until arg.arg.toInt()) { + val item = parseItem(data, offset + consumed) + ret.add(item.item) + consumed += item.len + } + return Item(ret.toList(), consumed) + } + TYPE_MAP -> { + val ret = mutableMapOf() + var consumed = arg.len + for (i in 0 until arg.arg.toInt()) { + val key = parseItem(data, offset + consumed) + consumed += key.len + val value = parseItem(data, offset + consumed) + consumed += value.len + ret[key.item] = value.item + } + return Item(ret.toMap(), consumed) + } + else -> { + throw IllegalArgumentException("Bad type") + } + } + } + + private fun createArg(type: Int, arg: Long): ByteArray { + val t = type shl 5 + val a = arg.toInt() + if (arg < 24) { + return byteArrayOf(((t or a) and 0xFF).toByte()) + } + if (arg <= 0xFF) { + return byteArrayOf(((t or 24) and 0xFF).toByte(), (a and 0xFF).toByte()) + } + if (arg <= 0xFFFF) { + return byteArrayOf( + ((t or 25) and 0xFF).toByte(), + ((a shr 8) and 0xFF).toByte(), + (a and 0xFF).toByte() + ) + } + if (arg <= 0xFFFFFFFF) { + return byteArrayOf( + ((t or 26) and 0xFF).toByte(), + ((a shr 24) and 0xFF).toByte(), + ((a shr 16) and 0xFF).toByte(), + ((a shr 8) and 0xFF).toByte(), + (a and 0xFF).toByte() + ) + } + throw IllegalArgumentException("bad Arg") + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataDefinedResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataDefinedResponse.kt new file mode 100644 index 000000000..ae323b904 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataDefinedResponse.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +open class ClientDataDefinedResponse( + private val clientDataHash: ByteArray +): ClientDataResponse { + + override fun hashData(): ByteArray { + return clientDataHash + } + + override fun buildResponse(): String { + return CLIENT_DATA_JSON_PRIVILEGED + } + + companion object { + private const val CLIENT_DATA_JSON_PRIVILEGED = "" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataNotDefinedResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataNotDefinedResponse.kt new file mode 100644 index 000000000..d7b9bc4f3 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataNotDefinedResponse.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import com.kunzisoft.encrypt.HashManager +import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper.Companion.b64Encode +import org.json.JSONObject + +open class ClientDataNotDefinedResponse( + type: Type, + challenge: ByteArray, + origin: String, + crossOrigin: Boolean? = null, + topOrigin: String? = null, + packageName: String? +): AuthenticatorResponse, ClientDataResponse { + override var clientJson = JSONObject() + + init { + // https://w3c.github.io/webauthn/#client-data + clientJson.put("type", type.value) + clientJson.put("challenge", b64Encode(challenge)) + clientJson.put("origin", origin) + crossOrigin?.let { + clientJson.put("crossOrigin", it) + } + topOrigin?.let { + clientJson.put("topOrigin", it) + } + packageName?.let { + clientJson.put("androidPackageName", packageName) + } + } + + override fun json(): JSONObject { + return clientJson + } + + enum class Type(val value: String) { + GET("webauthn.get"), CREATE("webauthn.create") + } + + override fun buildResponse(): String { + return b64Encode(json().toString().toByteArray()) + } + + override fun hashData(): ByteArray { + return HashManager.hashSha256(json().toString().toByteArray()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataResponse.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataResponse.kt new file mode 100644 index 000000000..bdc5b3ec2 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/ClientDataResponse.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +interface ClientDataResponse { + fun hashData(): ByteArray + fun buildResponse(): String +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoDataTypes.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoDataTypes.kt new file mode 100644 index 000000000..2f128d1d3 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoDataTypes.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kunzisoft.keepass.credentialprovider.passkey.data + +data class PublicKeyCredentialRpEntity(val name: String, val id: String) + +data class PublicKeyCredentialUserEntity( + val name: String, + val id: ByteArray, + val displayName: String +) + +data class PublicKeyCredentialParameters(val type: String, val alg: Long) + +data class PublicKeyCredentialDescriptor( + val type: String, + val id: ByteArray, + val transports: List +) + +data class AuthenticatorSelectionCriteria( + val authenticatorAttachment: String, + val residentKey: String, + val requireResidentKey: Boolean = false, + val userVerification: String = "preferred" +) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoPublicKeyCredential.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoPublicKeyCredential.kt new file mode 100644 index 000000000..9fa66a8a8 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/FidoPublicKeyCredential.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.data + +import org.json.JSONObject + +class FidoPublicKeyCredential( + val id: String, + val response: AuthenticatorResponse, + val authenticatorAttachment: String +) { + + fun json(): String { + // see at https://www.w3.org/TR/webauthn-3/#sctn-authenticator-credential-properties-extension + val discoverableCredential = true + val rk = JSONObject() + rk.put("rk", discoverableCredential) + val credProps = JSONObject() + credProps.put("credProps", rk) + + // See RegistrationResponseJSON at + // https://w3c.github.io/webauthn/#ref-for-dom-publickeycredential-tojson + val ret = JSONObject() + ret.put("id", id) + ret.put("rawId", id) + ret.put("type", "public-key") + ret.put("authenticatorAttachment", authenticatorAttachment) + ret.put("response", response.json()) + ret.put("clientExtensionResults", JSONObject()) // TODO credProps + + return ret.toString() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt index a5f9fcc81..e58e37a7d 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt @@ -1,9 +1,72 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package com.kunzisoft.keepass.credentialprovider.passkey.data -data class PublicKeyCredentialCreationOptions( - val relyingParty: String, - val challenge: ByteArray, // TODO Equals Hashcode - val username: String, - val userId: ByteArray, // TODO Equals Hashcode - val keyTypeIdList: List -) \ No newline at end of file +import android.util.Log +import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper +import org.json.JSONObject + +class PublicKeyCredentialCreationOptions(requestJson: String) { + val json: JSONObject = JSONObject(requestJson) + + val relyingPartyEntity: PublicKeyCredentialRpEntity + val userEntity: PublicKeyCredentialUserEntity + val challenge: ByteArray + val pubKeyCredParams: List + + var timeout: Long + var excludeCredentials: List + var authenticatorSelection: AuthenticatorSelectionCriteria + var attestation: String + + init { + val rpJson = json.getJSONObject("rp") + relyingPartyEntity = PublicKeyCredentialRpEntity(rpJson.getString("name"), rpJson.getString("id")) + val rpUser = json.getJSONObject("user") + val userId = Base64Helper.b64Decode(rpUser.getString("id")) + userEntity = + PublicKeyCredentialUserEntity( + rpUser.getString("name"), + userId, + rpUser.getString("displayName") + ) + challenge = Base64Helper.b64Decode(json.getString("challenge")) + val pubKeyCredParamsJson = json.getJSONArray("pubKeyCredParams") + val pubKeyCredParamsTmp: MutableList = mutableListOf() + for (i in 0 until pubKeyCredParamsJson.length()) { + val e = pubKeyCredParamsJson.getJSONObject(i) + pubKeyCredParamsTmp.add( + PublicKeyCredentialParameters(e.getString("type"), e.getLong("alg")) + ) + } + pubKeyCredParams = pubKeyCredParamsTmp.toList() + + timeout = json.optLong("timeout", 0) + // TODO: Fix excludeCredentials and authenticatorSelection + excludeCredentials = emptyList() + authenticatorSelection = AuthenticatorSelectionCriteria("platform", "required") + attestation = json.optString("attestation", "none") + + Log.i("WebAuthn", "Challenge $challenge()") + Log.i("WebAuthn", "rp $relyingPartyEntity") + Log.i("WebAuthn", "user $userEntity") + Log.i("WebAuthn", "pubKeyCredParams $pubKeyCredParams") + Log.i("WebAuthn", "timeout $timeout") + Log.i("WebAuthn", "excludeCredentials $excludeCredentials") + Log.i("WebAuthn", "authenticatorSelection $authenticatorSelection") + Log.i("WebAuthn", "attestation $attestation") + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationParameters.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationParameters.kt index a92030ad0..304fba835 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationParameters.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationParameters.kt @@ -1,11 +1,29 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ package com.kunzisoft.keepass.credentialprovider.passkey.data import java.security.KeyPair data class PublicKeyCredentialCreationParameters( - val relyingParty: String, + val publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions, val credentialId: ByteArray, // TODO Equals Hashcode val signatureKey: Pair, - val isPrivilegedApp: Boolean, - val challenge: ByteArray, // TODO Equals Hashcode + val clientDataResponse: ClientDataResponse ) \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialRequestOptions.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialRequestOptions.kt index 686a7f866..a2cf97906 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialRequestOptions.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialRequestOptions.kt @@ -1,6 +1,31 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ package com.kunzisoft.keepass.credentialprovider.passkey.data -data class PublicKeyCredentialRequestOptions( - val relyingParty: String, - val challengeString: String -) \ No newline at end of file +import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper +import org.json.JSONObject + +class PublicKeyCredentialRequestOptions(requestJson: String) { + val json: JSONObject = JSONObject(requestJson) + val challenge: ByteArray = Base64Helper.b64Decode(json.getString("challenge")) + val timeout: Long = json.optLong("timeout", 0) + val rpId: String = json.optString("rpId", "") + val userVerification: String = json.optString("userVerification", "preferred") +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialUsageParameters.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialUsageParameters.kt index e67a42578..16b01a345 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialUsageParameters.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialUsageParameters.kt @@ -1,9 +1,25 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ package com.kunzisoft.keepass.credentialprovider.passkey.data data class PublicKeyCredentialUsageParameters( - val relyingParty: String, - val packageName: String? = null, - val clientDataHash: ByteArray?, // TODO Equals Hashcode - val isPrivilegedApp: Boolean, - val challenge: ByteArray, // TODO Equals Hashcode + val publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions, + val clientDataResponse: ClientDataResponse ) \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/Base64Helper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/Base64Helper.kt index fd83a3f2f..faebbfa40 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/Base64Helper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/Base64Helper.kt @@ -1,19 +1,41 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ package com.kunzisoft.keepass.credentialprovider.passkey.util -import org.apache.commons.codec.binary.Base64 +import android.util.Base64 class Base64Helper { companion object { - fun b64Decode(encodedString: String?): ByteArray { - return Base64.decodeBase64(encodedString) + fun b64Decode(encodedString: String): ByteArray { + return Base64.decode( + encodedString, + Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE + ) } fun b64Encode(data: ByteArray): String { - return android.util.Base64.encodeToString( + return Base64.encodeToString( data, - android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP or android.util.Base64.URL_SAFE + Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE ) } } diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/JsonHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/JsonHelper.kt deleted file mode 100644 index e080d32b5..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/JsonHelper.kt +++ /dev/null @@ -1,249 +0,0 @@ -package com.kunzisoft.keepass.credentialprovider.passkey.util - -import android.annotation.SuppressLint -import androidx.credentials.webauthn.Cbor -import com.kunzisoft.encrypt.HashManager -import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationOptions -import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRequestOptions -import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper.Companion.b64Decode -import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper.Companion.b64Encode -import org.json.JSONArray -import org.json.JSONObject - -class JsonHelper { - - companion object { - - fun generateClientDataJsonNonPrivileged( - challenge: ByteArray, - origin: String, - packageName: String?, - isGet: Boolean, - isCrossOriginAdded: Boolean - ): String { - val clientJson = JSONObject() - val type = if (isGet) { - "webauthn.get" - } else { - "webauthn.create" - } - clientJson.put("type", type) - clientJson.put("challenge", b64Encode(challenge)) - clientJson.put("origin", origin) - - if (isCrossOriginAdded) { - clientJson.put("crossOrigin", false) - } - - if (packageName != null) { - clientJson.put("androidPackageName", packageName) - } - - val clientDataFinal = clientJson.toString().replace("\\/", "/") - return clientDataFinal - } - - fun generateClientDataJsonPrivileged(): String { - // will be replaced by the clientData from the privileged app like a browser - return "" - } - - fun generateAuthDataForUsage( - rpId: ByteArray, - userPresent: Boolean, - userVerified: Boolean, - backupEligibility: Boolean, - backupState: Boolean, - attestedCredentialData: Boolean = false - ): ByteArray { - val rpHash = HashManager.hashSha256(rpId) - - // see https://www.w3.org/TR/webauthn-3/#table-authData - var flags = 0 - val one = 1 - if (userPresent) { - flags = flags or one.shl(0) - } - // bit at index 1 is reserved - - if (userVerified) { - flags = flags or one.shl(2) - } - if (backupEligibility) { - flags = flags or one.shl(3) - } - if (backupState) { - flags = flags or one.shl(4) - } - - // bit at index 5 is reserved - - if (attestedCredentialData) { - flags = flags or one.shl(6) - } - - // bit at index 7: Extension data included == false - - val signCount = byteArrayOf(0, 0, 0, 0) - - return rpHash + byteArrayOf(flags.toByte()) + signCount - } - - fun generateAuthDataForCreate( - rpId: ByteArray, - userPresent: Boolean, - userVerified: Boolean, - backupEligibility: Boolean, - backupState: Boolean, - credentialId: ByteArray, - credentialPublicKey: ByteArray - ): ByteArray { - val authDataPartOne = generateAuthDataForUsage( - rpId, - userPresent, - userVerified, - backupEligibility, - backupState, - attestedCredentialData = true - ) - - // Authenticator Attestation Globally Unique Identifier - val aaguid = ByteArray(16) { 0 } - - val credIdLen = - byteArrayOf((credentialId.size.shr(8)).toByte(), credentialId.size.toByte()) - - return authDataPartOne + aaguid + credIdLen + credentialId + credentialPublicKey - } - - fun generateDataTosSignNonPrivileged( - clientDataJson: String, - authenticatorData: ByteArray - ): ByteArray { - val hash = HashManager.hashSha256(clientDataJson.toByteArray()) - return authenticatorData + hash - } - - fun generateDataToSignPrivileged( - clientDataHash: ByteArray, - authenticatorData: ByteArray - ): ByteArray { - return authenticatorData + clientDataHash - } - - fun generateAttestationObject(authData: ByteArray): ByteArray { - val ao = mutableMapOf() - ao["fmt"] = "none" - ao["attStmt"] = emptyMap() - ao["authData"] = authData - return generateCborFromMap(ao) - } - - @SuppressLint("RestrictedApi") - fun generateCborFromMap(map: Map): ByteArray { - return Cbor().encode(map) - } - - fun createAuthenticatorAttestationResponseJSON( - credentialId: ByteArray, - clientDataJson: String, - attestationObject: ByteArray, - publicKeyCbor: ByteArray, - authData: ByteArray, - publicKeyTypeId: Long - ): String { - // See AuthenticatorAttestationResponseJSON at - // https://www.w3.org/TR/webauthn-3/#ref-for-dom-publickeycredential-tojson - - val rk = JSONObject() - - // see at https://www.w3.org/TR/webauthn-3/#sctn-authenticator-credential-properties-extension - val discoverableCredential = true - rk.put("rk", discoverableCredential) - val credProps = JSONObject() - credProps.put("credProps", rk) - - - val response = JSONObject() - response.put("attestationObject", b64Encode(attestationObject)) - response.put("clientDataJSON", clientDataJson) - response.put("transports", JSONArray(listOf("internal", "hybrid"))) - response.put("publicKeyAlgorithm", publicKeyTypeId) - response.put("publicKey", b64Encode(publicKeyCbor)) - response.put("authenticatorData", b64Encode(authData)) - - val all = JSONObject() - all.put("id", b64Encode(credentialId)) - all.put("rawId", b64Encode(credentialId)) - all.put("response", response) - all.put("type", "public-key") - all.put("clientExtensionResults", credProps) - all.put("authenticatorAttachment", "platform") - return all.toString() - } - - fun generateGetCredentialResponse( - clientDataJson: ByteArray, - authenticatorData: ByteArray, - signature: ByteArray, - userHandle: String, - id: String - ): String { - - val response = JSONObject() - response.put("clientDataJSON", b64Encode(clientDataJson)) - response.put("authenticatorData", b64Encode(authenticatorData)) - response.put("signature", b64Encode(signature)) - response.put("userHandle", userHandle) - - val ret = JSONObject() - ret.put("id", id) - ret.put("rawId", id) - ret.put("type", "public-key") - ret.put("authenticatorAttachment", "platform") - ret.put("response", response) - ret.put("clientExtensionResults", JSONObject()) - - return ret.toString() - } - - fun parseJsonToRequestOptions(requestJson: String): PublicKeyCredentialRequestOptions { - val jsonObject = JSONObject(requestJson) - - val challengeString = jsonObject.getString("challenge") - val relyingParty = jsonObject.optString("rpId", "") - - return PublicKeyCredentialRequestOptions(relyingParty, challengeString) - } - - fun parseJsonToCreateOptions(requestJson: String): PublicKeyCredentialCreationOptions { - val jsonObject = JSONObject(requestJson) - val rpJson = jsonObject.getJSONObject("rp") - val relyingParty = rpJson.getString("id") - - val challenge = b64Decode(jsonObject.getString("challenge")) - - val rpUser = jsonObject.getJSONObject("user") - val username = rpUser.getString("name") - val userId = b64Decode(rpUser.getString("id")) - - - val pubKeyCredParamsJson = jsonObject.getJSONArray("pubKeyCredParams") - val keyTypeIdList: MutableList = mutableListOf() - for (i in 0 until pubKeyCredParamsJson.length()) { - val e = pubKeyCredParamsJson.getJSONObject(i) - if (e.getString("type") == "public-key") { - keyTypeIdList.add(e.getLong("alg")) - } - } - - return PublicKeyCredentialCreationOptions( - relyingParty, - challenge, - username, - userId, - keyTypeIdList.distinct() - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginHelper.kt deleted file mode 100644 index 6d1895964..000000000 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginHelper.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.kunzisoft.keepass.credentialprovider.passkey.util - -import android.content.res.AssetManager -import androidx.credentials.provider.CallingAppInfo - -class OriginHelper { - - companion object { - - const val DEFAULT_PROTOCOL = "https://" - - fun getWebOrigin(callingAppInfo: CallingAppInfo?, assets: AssetManager): String? { - val privilegedAllowlist = assets.open("trustedPackages.json").bufferedReader().use { - it.readText() - } - // for trusted browsers like Chrome and Firefox - return callingAppInfo?.getOrigin(privilegedAllowlist)?.removeSuffix("/") - } - - } -} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginManager.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginManager.kt new file mode 100644 index 000000000..e743f9eac --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginManager.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ +package com.kunzisoft.keepass.credentialprovider.passkey.util + +import android.content.res.AssetManager +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.credentials.provider.CallingAppInfo + +@RequiresApi(Build.VERSION_CODES.P) +class OriginManager( + callingAppInfo: CallingAppInfo?, + assets: AssetManager, + private val relyingParty: String +) { + private val webOrigin: String? + private val apkSigningCertificate: ByteArray? = callingAppInfo?.signingInfo?.apkContentsSigners + ?.getOrNull(0)?.toByteArray() + + init { + val privilegedAllowlist = assets.open("trustedPackages.json").bufferedReader().use { + it.readText() + } + // for trusted browsers like Chrome and Firefox + webOrigin = callingAppInfo?.getOrigin(privilegedAllowlist)?.removeSuffix("/") + } + + private fun isPrivilegedApp(): Boolean { + return webOrigin != null + && webOrigin == (DEFAULT_PROTOCOL + relyingParty) + } + + // TODO isPrivileged app + fun checkPrivilegedApp( + clientDataHash: ByteArray? + ) { + val isPrivilegedApp = isPrivilegedApp() && clientDataHash != null + Log.d(TAG, "isPrivilegedApp = $isPrivilegedApp") + if (!isPrivilegedApp) { + AppRelyingPartyRelation.isRelationValid(relyingParty, apkSigningCertificate) + } + } + + fun checkPrivilegedApp() { + val isPrivilegedApp = isPrivilegedApp() + Log.d(TAG, "isPrivilegedApp = $isPrivilegedApp") + if (!isPrivilegedApp) { + AppRelyingPartyRelation.isRelationValid(relyingParty, apkSigningCertificate) + } + } + + val origin: String + get() { + return webOrigin ?: (DEFAULT_PROTOCOL + relyingParty) + } + + companion object { + private val TAG = OriginManager::class.simpleName + const val DEFAULT_PROTOCOL = "https://" + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt index 6ff17147d..fa3ea0ce1 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt @@ -22,6 +22,7 @@ package com.kunzisoft.keepass.credentialprovider.passkey.util import android.app.Activity import android.content.Context import android.content.Intent +import android.content.res.AssetManager import android.os.Build import android.os.Bundle import android.os.ParcelUuid @@ -36,11 +37,21 @@ import androidx.credentials.PublicKeyCredential import androidx.credentials.exceptions.CreateCredentialUnknownException import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.provider.PendingIntentHandler +import androidx.credentials.provider.ProviderCreateCredentialRequest +import androidx.credentials.provider.ProviderGetCredentialRequest import com.kunzisoft.asymmetric.Signature +import com.kunzisoft.keepass.credentialprovider.passkey.data.AuthenticatorAssertionResponse +import com.kunzisoft.keepass.credentialprovider.passkey.data.AuthenticatorAttestationResponse +import com.kunzisoft.keepass.credentialprovider.passkey.data.Cbor +import com.kunzisoft.keepass.credentialprovider.passkey.data.ClientDataDefinedResponse +import com.kunzisoft.keepass.credentialprovider.passkey.data.ClientDataNotDefinedResponse +import com.kunzisoft.keepass.credentialprovider.passkey.data.FidoPublicKeyCredential import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationOptions import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters +import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRequestOptions import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters -import com.kunzisoft.keepass.credentialprovider.passkey.util.OriginHelper.Companion.DEFAULT_PROTOCOL +import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper.Companion.b64Encode +import com.kunzisoft.keepass.credentialprovider.passkey.util.OriginManager.Companion.DEFAULT_PROTOCOL import com.kunzisoft.keepass.model.EntryInfo import com.kunzisoft.keepass.model.EntryInfoPasskey.getPasskey import com.kunzisoft.keepass.model.Passkey @@ -206,20 +217,18 @@ object PasskeyHelper { return chunked(2).map { it.toInt(16).toByte() }.toByteArray() } - fun Intent.retrievePasskeyCreationComponent(): PublicKeyCredentialCreationOptions { - val request = PendingIntentHandler.retrieveProviderCreateCredentialRequest(this) - ?: throw CreateCredentialUnknownException("could not retrieve request from intent") + fun ProviderCreateCredentialRequest.retrievePasskeyCreationComponent(): PublicKeyCredentialCreationOptions { + val request = this if (request.callingRequest !is CreatePublicKeyCredentialRequest) { throw CreateCredentialUnknownException("callingRequest is of wrong type: ${request.callingRequest.type}") } - return JsonHelper.parseJsonToCreateOptions( + return PublicKeyCredentialCreationOptions( (request.callingRequest as CreatePublicKeyCredentialRequest).requestJson ) } - fun Intent.retrievePasskeyUsageComponent(): GetPublicKeyCredentialOption { - val request = PendingIntentHandler.retrieveProviderGetCredentialRequest(this) - ?: throw CreateCredentialUnknownException("could not retrieve request from intent") + fun ProviderGetCredentialRequest.retrievePasskeyUsageComponent(): GetPublicKeyCredentialOption { + val request = this if (request.credentialOptions.size != 1) { throw GetCredentialUnknownException("not exact one credentialOption") } @@ -230,36 +239,31 @@ object PasskeyHelper { } fun retrievePasskeyCreationRequestParameters( - creationOptions: PublicKeyCredentialCreationOptions, - webOrigin: String?, - apkSigningCertificate: ByteArray?, + intent: Intent, + assetManager: AssetManager, + packageName: String?, passkeyCreated: (Passkey, PublicKeyCredentialCreationParameters) -> Unit ) { - val relyingParty = creationOptions.relyingParty - val username = creationOptions.username - val userHandle = creationOptions.userId - val keyTypeIdList = creationOptions.keyTypeIdList - val challenge = creationOptions.challenge + val getCredentialRequest = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) + val callingAppInfo = getCredentialRequest?.callingAppInfo + val createCredentialRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(intent) + if (createCredentialRequest == null) + throw CreateCredentialUnknownException("could not retrieve request from intent") + val creationOptions = createCredentialRequest.retrievePasskeyCreationComponent() - val isPrivilegedApp = - (webOrigin != null && webOrigin == DEFAULT_PROTOCOL + relyingParty) - Log.d(this::class.java.simpleName, "isPrivilegedApp = $isPrivilegedApp") + val relyingParty = creationOptions.relyingPartyEntity.id + val username = creationOptions.userEntity.name + val userHandle = creationOptions.userEntity.id + val pubKeyCredParams = creationOptions.pubKeyCredParams - if (!isPrivilegedApp) { - val isValid = - AppRelyingPartyRelation.isRelationValid(relyingParty, apkSigningCertificate) - if (!isValid) { - throw CreateCredentialUnknownException( - "could not verify relation between app " + - "and relyingParty $relyingParty" - ) - } - } + val originManager = OriginManager(callingAppInfo, assetManager, relyingParty) + originManager.checkPrivilegedApp() val credentialId = KeePassDXRandom.generateCredentialId() - val (keyPair, keyTypeId) = Signature.generateKeyPair(keyTypeIdList) - ?: throw CreateCredentialUnknownException("no known public key type found") + val (keyPair, keyTypeId) = Signature.generateKeyPair( + pubKeyCredParams.map { params -> params.alg } + ) ?: throw CreateCredentialUnknownException("no known public key type found") val privateKeyPem = Signature.convertPrivateKeyToPem(keyPair.private) // create new entry in database @@ -268,68 +272,51 @@ object PasskeyHelper { username = username, displayName = "$relyingParty (Passkey)", privateKeyPem = privateKeyPem, - credentialId = Base64Helper.b64Encode(credentialId), - userHandle = Base64Helper.b64Encode(userHandle), + credentialId = b64Encode(credentialId), + userHandle = b64Encode(userHandle), relyingParty = DEFAULT_PROTOCOL + relyingParty ), PublicKeyCredentialCreationParameters( - relyingParty = relyingParty, - challenge = challenge, + publicKeyCredentialCreationOptions = creationOptions, credentialId = credentialId, signatureKey = Pair(keyPair, keyTypeId), - isPrivilegedApp = isPrivilegedApp + clientDataResponse = ClientDataNotDefinedResponse( + type = ClientDataNotDefinedResponse.Type.CREATE, + challenge = creationOptions.challenge, + origin = originManager.origin, + packageName = packageName + ) ) ) } fun buildCreatePublicKeyCredentialResponse( - packageName: String?, publicKeyCredentialCreationParameters: PublicKeyCredentialCreationParameters ): CreatePublicKeyCredentialResponse { val keyPair = publicKeyCredentialCreationParameters.signatureKey.first val keyTypeId = publicKeyCredentialCreationParameters.signatureKey.second - - val publicKeyEncoded = Signature.convertPublicKey(keyPair.public, keyTypeId) - val publicKeyMap = Signature.convertPublicKeyToMap(keyPair.public, keyTypeId) - - val authData = JsonHelper.generateAuthDataForCreate( - userPresent = true, - userVerified = true, - backupEligibility = true, - backupState = true, - rpId = publicKeyCredentialCreationParameters.relyingParty.toByteArray(), - credentialId = publicKeyCredentialCreationParameters.credentialId, - credentialPublicKey = JsonHelper.generateCborFromMap(publicKeyMap!!) - ) - - val attestationObject = JsonHelper.generateAttestationObject(authData) - - val clientJson: String - if (publicKeyCredentialCreationParameters.isPrivilegedApp) { - clientJson = JsonHelper.generateClientDataJsonPrivileged() - } else { - val origin = DEFAULT_PROTOCOL + publicKeyCredentialCreationParameters.relyingParty - clientJson = JsonHelper.generateClientDataJsonNonPrivileged( - publicKeyCredentialCreationParameters.challenge, - origin, - packageName, - isCrossOriginAdded = true, - isGet = false - ) - } - - val responseJson = JsonHelper.createAuthenticatorAttestationResponseJSON( - publicKeyCredentialCreationParameters.credentialId, - clientJson, - attestationObject, - publicKeyEncoded!!, - authData, - keyTypeId - ) - + val responseJson = FidoPublicKeyCredential( + id = b64Encode(publicKeyCredentialCreationParameters.credentialId), + response = AuthenticatorAttestationResponse( + requestOptions = publicKeyCredentialCreationParameters.publicKeyCredentialCreationOptions, + credentialId = publicKeyCredentialCreationParameters.credentialId, + credentialPublicKey = Cbor().encode(Signature.convertPublicKeyToMap( + publicKeyIn = keyPair.public, + keyTypeId = keyTypeId + ) ?: mapOf()), + userPresent = true, + userVerified = true, + backupEligibility = true, + backupState = true, + publicKeyTypeId = keyTypeId, + publicKeyCbor = Signature.convertPublicKey(keyPair.public, keyTypeId)!!, + clientDataResponse = publicKeyCredentialCreationParameters.clientDataResponse + ), + authenticatorAttachment = "platform" + ).json() // log only the length to prevent logging sensitive information - Log.d(javaClass.simpleName, "responseJson with length ${responseJson.length} created") + Log.d(javaClass.simpleName, "Json response for key creation") return CreatePublicKeyCredentialResponse(responseJson) } @@ -338,42 +325,32 @@ object PasskeyHelper { intent: Intent, result: (PublicKeyCredentialUsageParameters) -> Unit ) { - val callingAppInfo = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent)?.callingAppInfo - val credentialOption = intent.retrievePasskeyUsageComponent() + val getCredentialRequest = PendingIntentHandler.retrieveProviderGetCredentialRequest(intent) + if (getCredentialRequest == null) + throw CreateCredentialUnknownException("could not retrieve request from intent") + val callingAppInfo = getCredentialRequest.callingAppInfo + val credentialOption = getCredentialRequest.retrievePasskeyUsageComponent() val clientDataHash = credentialOption.clientDataHash - val requestOptions = JsonHelper.parseJsonToRequestOptions(credentialOption.requestJson) + val requestOptions = PublicKeyCredentialRequestOptions(credentialOption.requestJson) + val relyingParty = requestOptions.rpId - val relyingParty = requestOptions.relyingParty - val challenge = Base64Helper.b64Decode(requestOptions.challengeString) - val packageName = callingAppInfo?.packageName - val webOrigin = OriginHelper.getWebOrigin(callingAppInfo, context.assets) - - val isPrivilegedApp = - (webOrigin != null && webOrigin == DEFAULT_PROTOCOL + relyingParty && clientDataHash != null) - - Log.d(javaClass.simpleName, "isPrivilegedApp = $isPrivilegedApp") - - if (!isPrivilegedApp) { - if (!AppRelyingPartyRelation.isRelationValid( - relyingParty, - apkSigningCertificate = callingAppInfo?.signingInfo?.apkContentsSigners - ?.getOrNull(0)?.toByteArray() - )) { - throw CreateCredentialUnknownException( - "could not verify relation between app " + - "and relyingParty $relyingParty" - ) - } - } + val originManager = OriginManager(callingAppInfo, context.assets, relyingParty) + originManager.checkPrivilegedApp(clientDataHash) result.invoke( PublicKeyCredentialUsageParameters( - relyingParty = relyingParty, - packageName = packageName, - clientDataHash = clientDataHash, - isPrivilegedApp = isPrivilegedApp, - challenge = challenge + publicKeyCredentialRequestOptions = requestOptions, + clientDataResponse = clientDataHash?.let { + ClientDataDefinedResponse(clientDataHash) + } ?: run { + ClientDataNotDefinedResponse( + type = ClientDataNotDefinedResponse.Type.GET, + challenge = requestOptions.challenge, + origin = originManager.origin, + packageName = callingAppInfo.packageName + ) + } ) ) } @@ -382,46 +359,21 @@ object PasskeyHelper { usageParameters: PublicKeyCredentialUsageParameters, passkey: Passkey ): PublicKeyCredential { - - // https://www.w3.org/TR/webauthn-3/#authdata-flags - val authenticatorData = JsonHelper.generateAuthDataForUsage( - usageParameters.relyingParty.toByteArray(), - userPresent = true, - userVerified = true, - backupEligibility = true, - backupState = true - ) - - val clientDataJson: String - val dataToSign: ByteArray - if (usageParameters.isPrivilegedApp) { - clientDataJson = JsonHelper.generateClientDataJsonPrivileged() - dataToSign = - JsonHelper.generateDataToSignPrivileged(usageParameters.clientDataHash!!, authenticatorData) - } else { - val origin = DEFAULT_PROTOCOL + usageParameters.relyingParty - clientDataJson = JsonHelper.generateClientDataJsonNonPrivileged( - usageParameters.challenge, - origin, - usageParameters.packageName, - isGet = true, - isCrossOriginAdded = false - ) - dataToSign = - JsonHelper.generateDataTosSignNonPrivileged(clientDataJson, authenticatorData) - } - - val signature = Signature.sign(passkey.privateKeyPem, dataToSign) - ?: throw GetCredentialUnknownException("signing failed") - - val getCredentialResponse = - JsonHelper.generateGetCredentialResponse( - clientDataJson.toByteArray(), - authenticatorData, - signature, - passkey.userHandle, - passkey.credentialId - ) + val getCredentialResponse = FidoPublicKeyCredential( + id = passkey.credentialId, + response = AuthenticatorAssertionResponse( + requestOptions = usageParameters.publicKeyCredentialRequestOptions, + userPresent = true, + userVerified = true, + backupEligibility = true, + backupState = true, + userHandle = passkey.userHandle, + privateKey = passkey.privateKeyPem, + clientDataResponse = usageParameters.clientDataResponse + ), + authenticatorAttachment = "platform" + ).json() + Log.d(javaClass.simpleName, "Json response for key usage") return PublicKeyCredential(getCredentialResponse) } diff --git a/crypto/src/main/java/com/kunzisoft/asymmetric/Signature.kt b/crypto/src/main/java/com/kunzisoft/asymmetric/Signature.kt index e311cabb2..f1aaee828 100644 --- a/crypto/src/main/java/com/kunzisoft/asymmetric/Signature.kt +++ b/crypto/src/main/java/com/kunzisoft/asymmetric/Signature.kt @@ -23,9 +23,7 @@ import java.security.spec.ECGenParameterSpec object Signature { // see at https://www.iana.org/assignments/cose/cose.xhtml - const val ES256_ALGORITHM: Long = -7 - const val RS256_ALGORITHM: Long = -257 private const val RS256_KEY_SIZE_IN_BITS = 2048 @@ -171,6 +169,4 @@ object Signature { Log.e(this::class.java.simpleName, "convertPublicKeyToMap: no known key type id found") return null } - - } \ No newline at end of file diff --git a/crypto/src/main/java/com/kunzisoft/random/KeePassDXRandom.kt b/crypto/src/main/java/com/kunzisoft/random/KeePassDXRandom.kt index 7a54efe0d..98dda7162 100644 --- a/crypto/src/main/java/com/kunzisoft/random/KeePassDXRandom.kt +++ b/crypto/src/main/java/com/kunzisoft/random/KeePassDXRandom.kt @@ -1,3 +1,22 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ package com.kunzisoft.random import java.security.SecureRandom @@ -15,7 +34,5 @@ class KeePassDXRandom { internalSecureRandom.nextBytes(credentialId) return credentialId } - } - } \ No newline at end of file From 88b701fd39bd89bc68e4ccb2e7880968a9033513 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Sat, 16 Aug 2025 20:23:03 +0200 Subject: [PATCH 011/136] fix: Remove Play store dependency --- app/build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/app/build.gradle b/app/build.gradle index 186c54ea9..8e74310ce 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -151,7 +151,6 @@ dependencies { // Credentials Provider implementation "androidx.credentials:credentials:1.2.2" - implementation "androidx.credentials:credentials-play-services-auth:1.2.2" // Modules import implementation project(path: ':database') From d0ab5267cfe27ff9bfee4a85c4f681b5a09dfd83 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Sun, 17 Aug 2025 09:55:23 +0200 Subject: [PATCH 012/136] fix: Retrieve client data hash --- .../PublicKeyCredentialCreationOptions.kt | 49 ++++++++++++------- .../passkey/util/OriginManager.kt | 16 +----- .../passkey/util/PasskeyHelper.kt | 23 ++++++--- 3 files changed, 47 insertions(+), 41 deletions(-) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt index e58e37a7d..66fa14152 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/data/PublicKeyCredentialCreationOptions.kt @@ -1,17 +1,21 @@ /* - * Copyright 2023 The Android Open Source Project + * Copyright 2025 Jeremy Jamet / Kunzisoft. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at + * This file is part of KeePassDX. * - * http://www.apache.org/licenses/LICENSE-2.0 + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. */ package com.kunzisoft.keepass.credentialprovider.passkey.data @@ -19,7 +23,10 @@ import android.util.Log import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper import org.json.JSONObject -class PublicKeyCredentialCreationOptions(requestJson: String) { +class PublicKeyCredentialCreationOptions( + requestJson: String, + var clientDataHash: ByteArray? +) { val json: JSONObject = JSONObject(requestJson) val relyingPartyEntity: PublicKeyCredentialRpEntity @@ -60,13 +67,17 @@ class PublicKeyCredentialCreationOptions(requestJson: String) { authenticatorSelection = AuthenticatorSelectionCriteria("platform", "required") attestation = json.optString("attestation", "none") - Log.i("WebAuthn", "Challenge $challenge()") - Log.i("WebAuthn", "rp $relyingPartyEntity") - Log.i("WebAuthn", "user $userEntity") - Log.i("WebAuthn", "pubKeyCredParams $pubKeyCredParams") - Log.i("WebAuthn", "timeout $timeout") - Log.i("WebAuthn", "excludeCredentials $excludeCredentials") - Log.i("WebAuthn", "authenticatorSelection $authenticatorSelection") - Log.i("WebAuthn", "attestation $attestation") + Log.i(TAG, "challenge $challenge()") + Log.i(TAG, "rp $relyingPartyEntity") + Log.i(TAG, "user $userEntity") + Log.i(TAG, "pubKeyCredParams $pubKeyCredParams") + Log.i(TAG, "timeout $timeout") + Log.i(TAG, "excludeCredentials $excludeCredentials") + Log.i(TAG, "authenticatorSelection $authenticatorSelection") + Log.i(TAG, "attestation $attestation") + } + + companion object { + private val TAG = PublicKeyCredentialCreationOptions::class.simpleName } } diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginManager.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginManager.kt index e743f9eac..65dcb7414 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginManager.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginManager.kt @@ -43,24 +43,12 @@ class OriginManager( webOrigin = callingAppInfo?.getOrigin(privilegedAllowlist)?.removeSuffix("/") } - private fun isPrivilegedApp(): Boolean { - return webOrigin != null - && webOrigin == (DEFAULT_PROTOCOL + relyingParty) - } - // TODO isPrivileged app fun checkPrivilegedApp( clientDataHash: ByteArray? ) { - val isPrivilegedApp = isPrivilegedApp() && clientDataHash != null - Log.d(TAG, "isPrivilegedApp = $isPrivilegedApp") - if (!isPrivilegedApp) { - AppRelyingPartyRelation.isRelationValid(relyingParty, apkSigningCertificate) - } - } - - fun checkPrivilegedApp() { - val isPrivilegedApp = isPrivilegedApp() + val isPrivilegedApp = webOrigin != null + && webOrigin == (DEFAULT_PROTOCOL + relyingParty) && clientDataHash != null Log.d(TAG, "isPrivilegedApp = $isPrivilegedApp") if (!isPrivilegedApp) { AppRelyingPartyRelation.isRelationValid(relyingParty, apkSigningCertificate) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt index fa3ea0ce1..a26a59738 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt @@ -222,8 +222,10 @@ object PasskeyHelper { if (request.callingRequest !is CreatePublicKeyCredentialRequest) { throw CreateCredentialUnknownException("callingRequest is of wrong type: ${request.callingRequest.type}") } + val createPublicKeyCredentialRequest = request.callingRequest as CreatePublicKeyCredentialRequest return PublicKeyCredentialCreationOptions( - (request.callingRequest as CreatePublicKeyCredentialRequest).requestJson + requestJson = createPublicKeyCredentialRequest.requestJson, + clientDataHash = createPublicKeyCredentialRequest.clientDataHash ) } @@ -255,9 +257,10 @@ object PasskeyHelper { val username = creationOptions.userEntity.name val userHandle = creationOptions.userEntity.id val pubKeyCredParams = creationOptions.pubKeyCredParams + val clientDataHash = creationOptions.clientDataHash val originManager = OriginManager(callingAppInfo, assetManager, relyingParty) - originManager.checkPrivilegedApp() + originManager.checkPrivilegedApp(clientDataHash) val credentialId = KeePassDXRandom.generateCredentialId() @@ -280,12 +283,16 @@ object PasskeyHelper { publicKeyCredentialCreationOptions = creationOptions, credentialId = credentialId, signatureKey = Pair(keyPair, keyTypeId), - clientDataResponse = ClientDataNotDefinedResponse( - type = ClientDataNotDefinedResponse.Type.CREATE, - challenge = creationOptions.challenge, - origin = originManager.origin, - packageName = packageName - ) + clientDataResponse = clientDataHash?.let { + ClientDataDefinedResponse(clientDataHash) + } ?: run { + ClientDataNotDefinedResponse( + type = ClientDataNotDefinedResponse.Type.CREATE, + challenge = creationOptions.challenge, + origin = originManager.origin, + packageName = packageName + ) + } ) ) } From a9e8de26f8a249dbefd6bdbb858a42528d2c04a2 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Wed, 20 Aug 2025 09:13:28 +0200 Subject: [PATCH 013/136] fix: client data hash --- .../credentialprovider/passkey/PasskeyProviderService.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt index 96312864c..83be08484 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt @@ -251,7 +251,10 @@ class PasskeyProviderService : CredentialProviderService() { val accountName = mDatabase?.name ?: getString(R.string.passkey_locked_database_username) val createEntries: MutableList = mutableListOf() - val relyingPartyId = PublicKeyCredentialCreationOptions(request.requestJson).relyingPartyEntity.id + val relyingPartyId = PublicKeyCredentialCreationOptions( + requestJson = request.requestJson, + clientDataHash = request.clientDataHash + ).relyingPartyEntity.id val searchInfo = SearchInfo().apply { relyingParty = relyingPartyId } From d0c0c4a4d6d7e8169542fef31b88dfd6e0142f21 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Wed, 20 Aug 2025 10:11:29 +0200 Subject: [PATCH 014/136] fix: gitignore .kotlin --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d02280737..3cd74fd62 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ bin/ gen/ out/ +# Kotlin folder +.kotlin/ + # Gradle files .gradle/ build/ From e3083c7773dbaac76bbb570a4062773c5720c033 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Wed, 20 Aug 2025 10:11:55 +0200 Subject: [PATCH 015/136] fix: search parameters --- .../passkey/PasskeyProviderService.kt | 31 ++++++-- .../keepass/database/element/Database.kt | 2 +- .../com/kunzisoft/keepass/model/SearchInfo.kt | 70 ++++++------------- 3 files changed, 46 insertions(+), 57 deletions(-) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt index 83be08484..05b8356a5 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt @@ -84,6 +84,29 @@ class PasskeyProviderService : CredentialProviderService() { super.onDestroy() } + private fun buildPasskeySearchInfo(relyingParty: String): SearchInfo { + return SearchInfo().apply { + webDomain = relyingParty + searchParameters.apply { + allowEmptyQuery = false + searchInTitles = false + searchInUsernames = false + searchInPasswords = false + searchInUrls = true + searchInNotes = false + searchInOTP = false + searchParameters.searchInRelyingParty = true + searchInOther = false + searchInUUIDs = false + searchInTags = false + searchInCurrentGroup = false + searchInSearchableGroup = true + searchInRecycleBin = false + searchInTemplates = false + } + } + } + override fun onBeginGetCredentialRequest( request: BeginGetCredentialRequest, cancellationSignal: CancellationSignal, @@ -119,9 +142,7 @@ class PasskeyProviderService : CredentialProviderService() { val passkeyEntries: MutableList = mutableListOf() val relyingPartyId = PublicKeyCredentialRequestOptions(option.requestJson).rpId - val searchInfo = SearchInfo().apply { - relyingParty = relyingPartyId - } + val searchInfo = buildPasskeySearchInfo(relyingPartyId) Log.d(TAG, "Build passkey search for relying party $relyingPartyId") SearchHelper.checkAutoSearchInfo( context = this, @@ -255,9 +276,7 @@ class PasskeyProviderService : CredentialProviderService() { requestJson = request.requestJson, clientDataHash = request.clientDataHash ).relyingPartyEntity.id - val searchInfo = SearchInfo().apply { - relyingParty = relyingPartyId - } + val searchInfo = buildPasskeySearchInfo(relyingPartyId) Log.d(TAG, "Build passkey search for relying party $relyingPartyId") SearchHelper.checkAutoSearchInfo( context = this, diff --git a/database/src/main/java/com/kunzisoft/keepass/database/element/Database.kt b/database/src/main/java/com/kunzisoft/keepass/database/element/Database.kt index bc8e143c5..93f1ea407 100644 --- a/database/src/main/java/com/kunzisoft/keepass/database/element/Database.kt +++ b/database/src/main/java/com/kunzisoft/keepass/database/element/Database.kt @@ -891,7 +891,7 @@ open class Database { ): Group? { return mSearchHelper.createVirtualGroupWithSearchResult( database = this, - searchParameters = searchInfo.buildSearchParameters(), + searchParameters = searchInfo.searchParameters, fromGroup = null, max = max ) diff --git a/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt b/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt index a6639ed4f..7b96f21c1 100644 --- a/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt +++ b/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt @@ -35,9 +35,10 @@ class SearchInfo : ObjectNameResource, Parcelable { get() { return if (webDomain == null) null else field } - var relyingParty: String? = null var otpString: String? = null + var searchParameters: SearchParameters = buildSearchParameters() + constructor() constructor(toCopy: SearchInfo?) { @@ -46,7 +47,6 @@ class SearchInfo : ObjectNameResource, Parcelable { applicationId = toCopy?.applicationId webDomain = toCopy?.webDomain webScheme = toCopy?.webScheme - relyingParty = toCopy?.relyingParty otpString = toCopy?.otpString } @@ -60,8 +60,6 @@ class SearchInfo : ObjectNameResource, Parcelable { webDomain = if (readDomain.isNullOrEmpty()) null else readDomain val readScheme = parcel.readString() webScheme = if (readScheme.isNullOrEmpty()) null else readScheme - val readRelyingParty = parcel.readString() - relyingParty = if (readRelyingParty.isNullOrEmpty()) null else readRelyingParty val readOtp = parcel.readString() otpString = if (readOtp.isNullOrEmpty()) null else readOtp } @@ -76,7 +74,6 @@ class SearchInfo : ObjectNameResource, Parcelable { parcel.writeString(applicationId ?: "") parcel.writeString(webDomain ?: "") parcel.writeString(webScheme ?: "") - parcel.writeString(relyingParty ?: "") parcel.writeString(otpString ?: "") } @@ -94,7 +91,6 @@ class SearchInfo : ObjectNameResource, Parcelable { && applicationId == null && webDomain == null && webScheme == null - && relyingParty == null && otpString == null } @@ -102,48 +98,24 @@ class SearchInfo : ObjectNameResource, Parcelable { return toString() == webDomain && webDomain != null } - private fun isAPasskeySearch(): Boolean { - return toString() == relyingParty && relyingParty != null - } - - fun buildSearchParameters(): SearchParameters { + private fun buildSearchParameters(): SearchParameters { return SearchParameters().apply { - if (isAPasskeySearch()) { - searchQuery = relyingParty!! - allowEmptyQuery = false - searchInTitles = false - searchInUsernames = false - searchInPasswords = false - searchInUrls = false - searchByDomain = false - searchInNotes = false - searchInOTP = false - searchInOther = false - searchInUUIDs = false - searchInTags = false - searchInRelyingParty = true - searchInCurrentGroup = false - searchInSearchableGroup = false - searchInRecycleBin = false - searchInTemplates = false - } else { - searchQuery = toString() - allowEmptyQuery = false - searchInTitles = true - searchInUsernames = false - searchInPasswords = false - searchInUrls = true - searchByDomain = isASearchByDomain() - searchInNotes = true - searchInOTP = false - searchInOther = true - searchInUUIDs = false - searchInTags = false - searchInCurrentGroup = false - searchInSearchableGroup = true - searchInRecycleBin = false - searchInTemplates = false - } + searchQuery = toString() + allowEmptyQuery = false + searchInTitles = true + searchInUsernames = false + searchInPasswords = false + searchInUrls = true + searchByDomain = isASearchByDomain() + searchInNotes = true + searchInOTP = false + searchInOther = true + searchInUUIDs = false + searchInTags = false + searchInCurrentGroup = false + searchInSearchableGroup = true + searchInRecycleBin = false + searchInTemplates = false } } @@ -156,7 +128,6 @@ class SearchInfo : ObjectNameResource, Parcelable { if (applicationId != other.applicationId) return false if (webDomain != other.webDomain) return false if (webScheme != other.webScheme) return false - if (relyingParty != other.relyingParty) return false if (otpString != other.otpString) return false return true @@ -168,13 +139,12 @@ class SearchInfo : ObjectNameResource, Parcelable { result = 31 * result + (applicationId?.hashCode() ?: 0) result = 31 * result + (webDomain?.hashCode() ?: 0) result = 31 * result + (webScheme?.hashCode() ?: 0) - result = 31 * result + (relyingParty?.hashCode() ?: 0) result = 31 * result + (otpString?.hashCode() ?: 0) return result } override fun toString(): String { - return otpString ?: webDomain ?: applicationId ?: relyingParty ?: tag ?: "" + return otpString ?: webDomain ?: applicationId ?: tag ?: "" } companion object { From 44e8f4f40645e62bf4ad79370c23c57926067cc1 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Wed, 20 Aug 2025 12:29:24 +0200 Subject: [PATCH 016/136] fix: AutoSearch --- .../keepass/activities/GroupActivity.kt | 3 +- .../activity/PasskeyLauncherActivity.kt | 2 +- .../passkey/PasskeyProviderService.kt | 19 +----------- .../keepass/database/element/Database.kt | 2 +- .../com/kunzisoft/keepass/model/SearchInfo.kt | 31 +++++++++++++------ 5 files changed, 26 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt b/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt index acd1332f7..86e83accd 100644 --- a/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/activities/GroupActivity.kt @@ -1912,7 +1912,8 @@ class GroupActivity : DatabaseLockActivity(), context = activity, database = database, searchInfo = searchInfo, - activityResultLauncher = activityResultLauncher + activityResultLauncher = activityResultLauncher, + autoSearch = true ) onLaunchActivitySpecialMode() } diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt index e408607e0..bc6480db6 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt @@ -214,7 +214,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { database = openedDatabase, activityResultLauncher = mPasskeySelectionActivityResultLauncher, searchInfo = null, - autoSearch = true + autoSearch = false ) }, onDatabaseClosed = { diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt index 05b8356a5..d54fd811d 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt @@ -86,24 +86,7 @@ class PasskeyProviderService : CredentialProviderService() { private fun buildPasskeySearchInfo(relyingParty: String): SearchInfo { return SearchInfo().apply { - webDomain = relyingParty - searchParameters.apply { - allowEmptyQuery = false - searchInTitles = false - searchInUsernames = false - searchInPasswords = false - searchInUrls = true - searchInNotes = false - searchInOTP = false - searchParameters.searchInRelyingParty = true - searchInOther = false - searchInUUIDs = false - searchInTags = false - searchInCurrentGroup = false - searchInSearchableGroup = true - searchInRecycleBin = false - searchInTemplates = false - } + this.relyingParty = relyingParty } } diff --git a/database/src/main/java/com/kunzisoft/keepass/database/element/Database.kt b/database/src/main/java/com/kunzisoft/keepass/database/element/Database.kt index 93f1ea407..bc8e143c5 100644 --- a/database/src/main/java/com/kunzisoft/keepass/database/element/Database.kt +++ b/database/src/main/java/com/kunzisoft/keepass/database/element/Database.kt @@ -891,7 +891,7 @@ open class Database { ): Group? { return mSearchHelper.createVirtualGroupWithSearchResult( database = this, - searchParameters = searchInfo.searchParameters, + searchParameters = searchInfo.buildSearchParameters(), fromGroup = null, max = max ) diff --git a/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt b/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt index 7b96f21c1..bd09272cb 100644 --- a/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt +++ b/database/src/main/java/com/kunzisoft/keepass/model/SearchInfo.kt @@ -35,10 +35,9 @@ class SearchInfo : ObjectNameResource, Parcelable { get() { return if (webDomain == null) null else field } + var relyingParty: String? = null var otpString: String? = null - var searchParameters: SearchParameters = buildSearchParameters() - constructor() constructor(toCopy: SearchInfo?) { @@ -47,6 +46,7 @@ class SearchInfo : ObjectNameResource, Parcelable { applicationId = toCopy?.applicationId webDomain = toCopy?.webDomain webScheme = toCopy?.webScheme + relyingParty = toCopy?.relyingParty otpString = toCopy?.otpString } @@ -60,6 +60,8 @@ class SearchInfo : ObjectNameResource, Parcelable { webDomain = if (readDomain.isNullOrEmpty()) null else readDomain val readScheme = parcel.readString() webScheme = if (readScheme.isNullOrEmpty()) null else readScheme + val readRelyingParty = parcel.readString() + relyingParty = if (readRelyingParty.isNullOrEmpty()) null else readRelyingParty val readOtp = parcel.readString() otpString = if (readOtp.isNullOrEmpty()) null else readOtp } @@ -74,6 +76,7 @@ class SearchInfo : ObjectNameResource, Parcelable { parcel.writeString(applicationId ?: "") parcel.writeString(webDomain ?: "") parcel.writeString(webScheme ?: "") + parcel.writeString(relyingParty ?: "") parcel.writeString(otpString ?: "") } @@ -91,27 +94,33 @@ class SearchInfo : ObjectNameResource, Parcelable { && applicationId == null && webDomain == null && webScheme == null + && relyingParty == null && otpString == null } - private fun isASearchByDomain(): Boolean { + private fun isADomainSearch(): Boolean { return toString() == webDomain && webDomain != null } - private fun buildSearchParameters(): SearchParameters { + private fun isAPasskeySearch(): Boolean { + return toString() == relyingParty && relyingParty != null + } + + fun buildSearchParameters(): SearchParameters { return SearchParameters().apply { - searchQuery = toString() + searchQuery = this@SearchInfo.toString() allowEmptyQuery = false - searchInTitles = true + searchInTitles = !isAPasskeySearch() searchInUsernames = false searchInPasswords = false - searchInUrls = true - searchByDomain = isASearchByDomain() - searchInNotes = true + searchInUrls = !isAPasskeySearch() + searchByDomain = isADomainSearch() + searchInNotes = false searchInOTP = false searchInOther = true searchInUUIDs = false searchInTags = false + searchInRelyingParty = isAPasskeySearch() searchInCurrentGroup = false searchInSearchableGroup = true searchInRecycleBin = false @@ -128,6 +137,7 @@ class SearchInfo : ObjectNameResource, Parcelable { if (applicationId != other.applicationId) return false if (webDomain != other.webDomain) return false if (webScheme != other.webScheme) return false + if (relyingParty != other.relyingParty) return false if (otpString != other.otpString) return false return true @@ -139,12 +149,13 @@ class SearchInfo : ObjectNameResource, Parcelable { result = 31 * result + (applicationId?.hashCode() ?: 0) result = 31 * result + (webDomain?.hashCode() ?: 0) result = 31 * result + (webScheme?.hashCode() ?: 0) + result = 31 * result + (relyingParty?.hashCode() ?: 0) result = 31 * result + (otpString?.hashCode() ?: 0) return result } override fun toString(): String { - return otpString ?: webDomain ?: applicationId ?: tag ?: "" + return otpString ?: webDomain ?: applicationId ?: relyingParty ?: tag ?: "" } companion object { From 7e09532d5dd50397ce2d702870a8263153099516 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Wed, 20 Aug 2025 20:56:40 +0200 Subject: [PATCH 017/136] fix: Add check security --- .../activity/PasskeyLauncherActivity.kt | 57 +++++++++++-------- .../passkey/PasskeyProviderService.kt | 2 +- .../passkey/util/PasskeyHelper.kt | 54 +++++++++++------- 3 files changed, 68 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt index bc6480db6..40863f6f6 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt @@ -19,7 +19,6 @@ */ package com.kunzisoft.keepass.credentialprovider.activity -import android.app.Activity import android.app.PendingIntent import android.content.Context import android.content.Intent @@ -42,13 +41,17 @@ import com.kunzisoft.keepass.credentialprovider.TypeMode import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addAuthCode +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addNodeId +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addSearchInfo import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildCreatePublicKeyCredentialResponse import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.buildPasskeyPublicKeyCredential +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.checkSecurity import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.removePasskey import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveNodeId import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskey import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyCreationRequestParameters import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrievePasskeyUsageRequestParameters +import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveSearchInfo import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.element.node.NodeIdUUID import com.kunzisoft.keepass.database.helper.SearchHelper @@ -56,7 +59,6 @@ import com.kunzisoft.keepass.model.EntryInfoPasskey.getPasskey import com.kunzisoft.keepass.model.Passkey import com.kunzisoft.keepass.model.RegisterInfo import com.kunzisoft.keepass.model.SearchInfo -import com.kunzisoft.keepass.utils.getParcelableExtraCompat import java.util.UUID @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) @@ -138,17 +140,20 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { override fun onDatabaseRetrieved(database: ContextualDatabase?) { super.onDatabaseRetrieved(database) - // TODO nodeId Really useful ? checkSecurity(intent, nodeId) + val nodeId = intent.retrieveNodeId() + checkSecurity(intent, nodeId) when (mSpecialMode) { SpecialMode.SELECTION -> { - launchSelection(database, mSearchInfo) + launchSelection(database, nodeId, mSearchInfo) } SpecialMode.REGISTRATION -> { - launchRegistration(database, mSearchInfo) + // TODO Registration in predefined group + // launchRegistration(database, nodeId, mSearchInfo) + launchRegistration(database, null, mSearchInfo) } else -> { Log.e(TAG, "Passkey launch mode not supported") - setResult(Activity.RESULT_CANCELED) + setResult(RESULT_CANCELED) finish() } } @@ -187,6 +192,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { private fun launchSelection( database: ContextualDatabase?, + nodeId: UUID?, searchInfo: SearchInfo? ) { Log.d(TAG, "Launch passkey selection") @@ -194,7 +200,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { // Save the requested parameters mUsageParameters = usageParameters // Manage the passkey to use - intent.retrieveNodeId()?.let { nodeId -> + nodeId?.let { nodeId -> autoSelectPasskeyAndSetResult(database, nodeId) } ?: run { SearchHelper.checkAutoSearchInfo( @@ -232,9 +238,10 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { private fun autoRegisterPasskeyAndSetResult( database: ContextualDatabase?, - nodeId: UUID + nodeId: UUID, + passkey: Passkey ) { - // TODO Overwrite automatic selection + // TODO Overwrite and Register in a predefined group mCreationParameters?.let { creationParameters -> // To set the passkey to the database setResult(RESULT_OK) @@ -248,6 +255,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { private fun launchRegistration( database: ContextualDatabase?, + nodeId: UUID?, searchInfo: SearchInfo ) { Log.d(TAG, "Launch passkey registration") @@ -266,8 +274,8 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { passkey = passkey ) // If nodeId already provided - intent.retrieveNodeId()?.let { nodeId -> - autoRegisterPasskeyAndSetResult(database, nodeId) + nodeId?.let { nodeId -> + autoRegisterPasskeyAndSetResult(database, nodeId, passkey) } ?: run { SearchHelper.checkAutoSearchInfo( context = this, @@ -311,21 +319,21 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { companion object { private val TAG = PasskeyLauncherActivity::class.java.name - private const val EXTRA_SEARCH_INFO = "com.kunzisoft.keepass.extra.SEARCH_INFO" - - fun Intent.retrieveSearchInfo(): SearchInfo? { - return this.getParcelableExtraCompat(EXTRA_SEARCH_INFO) - } - - fun Intent.removeSearchInfo() { - return this.removeExtra(EXTRA_SEARCH_INFO) - } + /** + * Get a pending intent to launch the passkey launcher activity + * [nodeId] can be : + * - null if manual selection is requested + * - null if manual registration is requested + * - an entry node id if direct selection is requested + * - a group node id if direct registration is requested in a default group + * - an entry node id if overwriting is requested in an existing entry + */ fun getPendingIntent( context: Context, specialMode: SpecialMode, searchInfo: SearchInfo? = null, - passkeyEntryNodeId: UUID? = null + nodeId: UUID? = null ): PendingIntent? { return PendingIntent.getActivity( context, @@ -333,10 +341,9 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { Intent(context, PasskeyLauncherActivity::class.java).apply { addSpecialMode(specialMode) addTypeMode(TypeMode.PASSKEY) - searchInfo?.let { - putExtra(EXTRA_SEARCH_INFO, searchInfo) - } - addAuthCode(passkeyEntryNodeId) + addSearchInfo(searchInfo) + addNodeId(nodeId) + addAuthCode(nodeId) }, PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt index d54fd811d..7ec40a440 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt @@ -137,7 +137,7 @@ class PasskeyProviderService : CredentialProviderService() { PasskeyLauncherActivity.getPendingIntent( context = applicationContext, specialMode = SpecialMode.SELECTION, - passkeyEntryNodeId = passkeyEntry.id + nodeId = passkeyEntry.id )?.let { usagePendingIntent -> val passkey = passkeyEntry.getPasskey() passkeyEntries.add( diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt index a26a59738..1c16ff445 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt @@ -55,6 +55,7 @@ import com.kunzisoft.keepass.credentialprovider.passkey.util.OriginManager.Compa import com.kunzisoft.keepass.model.EntryInfo import com.kunzisoft.keepass.model.EntryInfoPasskey.getPasskey import com.kunzisoft.keepass.model.Passkey +import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.utils.StringUtil.toHexString import com.kunzisoft.keepass.utils.getParcelableExtraCompat import com.kunzisoft.random.KeePassDXRandom @@ -73,9 +74,11 @@ object PasskeyHelper { private const val HMAC_TYPE = "HmacSHA256" - private const val KEY_NODE_ID = "nodeId" - private const val KEY_TIMESTAMP = "timestamp" - private const val KEY_AUTHENTICATION_CODE = "authenticationCode" + + private const val EXTRA_SEARCH_INFO = "com.kunzisoft.keepass.extra.SEARCH_INFO" + private const val EXTRA_NODE_ID = "com.kunzisoft.keepass.extra.nodeId" + private const val EXTRA_TIMESTAMP = "com.kunzisoft.keepass.extra.timestamp" + private const val EXTRA_AUTHENTICATION_CODE = "com.kunzisoft.keepass.extra.authenticationCode" private const val SEPARATOR = "_" @@ -117,18 +120,16 @@ object PasskeyHelper { } fun Intent.addAuthCode(passkeyEntryNodeId: UUID? = null) { - passkeyEntryNodeId?.let { - putExtras(Bundle().apply { - val timestamp = Instant.now().epochSecond - putParcelable(KEY_NODE_ID, ParcelUuid(passkeyEntryNodeId)) - putString(KEY_TIMESTAMP, timestamp.toString()) - putString( - KEY_AUTHENTICATION_CODE, generatedAuthenticationCode( - passkeyEntryNodeId, timestamp - ).toHexString() - ) - }) - } + putExtras(Bundle().apply { + val timestamp = Instant.now().epochSecond + putString(EXTRA_TIMESTAMP, timestamp.toString()) + putString( + EXTRA_AUTHENTICATION_CODE, + generatedAuthenticationCode( + passkeyEntryNodeId, timestamp + ).toHexString() + ) + }) } fun Intent.retrievePasskey(): Passkey? { @@ -139,12 +140,28 @@ object PasskeyHelper { return this.removeExtra(EXTRA_PASSKEY_ELEMENT) } + fun Intent.addSearchInfo(searchInfo: SearchInfo?) { + searchInfo?.let { + putExtra(EXTRA_SEARCH_INFO, searchInfo) + } + } + + fun Intent.retrieveSearchInfo(): SearchInfo? { + return this.getParcelableExtraCompat(EXTRA_SEARCH_INFO) + } + + fun Intent.addNodeId(nodeId: UUID?) { + nodeId?.let { + putExtra(EXTRA_NODE_ID, ParcelUuid(nodeId)) + } + } + fun Intent.retrieveNodeId(): UUID? { - return getParcelableExtraCompat(KEY_NODE_ID)?.uuid + return getParcelableExtraCompat(EXTRA_NODE_ID)?.uuid } fun checkSecurity(intent: Intent, nodeId: UUID?) { - val timestampString = intent.getStringExtra(KEY_TIMESTAMP) + val timestampString = intent.getStringExtra(EXTRA_TIMESTAMP) if (timestampString.isNullOrEmpty()) throw CreateCredentialUnknownException("Timestamp null") if (timestampString.matches(REGEX_TIMESTAMP).not()) { @@ -155,9 +172,8 @@ object PasskeyHelper { if (diff < 0 || diff > MAX_DIFF_IN_SECONDS) { throw CreateCredentialUnknownException("Out of time") } - verifyAuthenticationCode( - intent.getStringExtra(KEY_AUTHENTICATION_CODE), + intent.getStringExtra(EXTRA_AUTHENTICATION_CODE), generatedAuthenticationCode(nodeId, timestamp) ) } From 40e8dea485879091f5fd7dc895c36c83651f27e8 Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Wed, 20 Aug 2025 21:13:07 +0200 Subject: [PATCH 018/136] fix: Capture exception --- .../activity/PasskeyLauncherActivity.kt | 37 +++++++++++-------- .../passkey/PasskeyProviderService.kt | 22 ++++++++--- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt index 40863f6f6..503a9dd35 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt @@ -25,6 +25,7 @@ import android.content.Intent import android.os.Build import android.os.Bundle import android.util.Log +import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.annotation.RequiresApi import androidx.credentials.GetCredentialResponse @@ -59,6 +60,7 @@ import com.kunzisoft.keepass.model.EntryInfoPasskey.getPasskey import com.kunzisoft.keepass.model.Passkey import com.kunzisoft.keepass.model.RegisterInfo import com.kunzisoft.keepass.model.SearchInfo +import java.io.InvalidObjectException import java.util.UUID @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) @@ -140,22 +142,27 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { override fun onDatabaseRetrieved(database: ContextualDatabase?) { super.onDatabaseRetrieved(database) - val nodeId = intent.retrieveNodeId() - checkSecurity(intent, nodeId) - when (mSpecialMode) { - SpecialMode.SELECTION -> { - launchSelection(database, nodeId, mSearchInfo) - } - SpecialMode.REGISTRATION -> { - // TODO Registration in predefined group - // launchRegistration(database, nodeId, mSearchInfo) - launchRegistration(database, null, mSearchInfo) - } - else -> { - Log.e(TAG, "Passkey launch mode not supported") - setResult(RESULT_CANCELED) - finish() + try { + val nodeId = intent.retrieveNodeId() + checkSecurity(intent, nodeId) + when (mSpecialMode) { + SpecialMode.SELECTION -> { + launchSelection(database, nodeId, mSearchInfo) + } + SpecialMode.REGISTRATION -> { + // TODO Registration in predefined group + // launchRegistration(database, nodeId, mSearchInfo) + launchRegistration(database, null, mSearchInfo) + } + else -> { + throw InvalidObjectException("Passkey launch mode not supported") + } } + } catch (e: Exception) { + Log.e(TAG, "Passkey launch error", e) + Toast.makeText(this, e.localizedMessage, Toast.LENGTH_LONG).show() + setResult(RESULT_CANCELED) + finish() } } diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt index 7ec40a440..6773fa3f0 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt @@ -96,9 +96,14 @@ class PasskeyProviderService : CredentialProviderService() { callback: OutcomeReceiver, ) { Log.d(javaClass.simpleName, "onBeginGetCredentialRequest called") - processGetCredentialsRequest(request)?.let { response -> - callback.onResult(response) - } ?: run { + try { + processGetCredentialsRequest(request)?.let { response -> + callback.onResult(response) + } ?: run { + callback.onError(GetCredentialUnknownException()) + } + } catch (e: Exception) { + Log.e(javaClass.simpleName, "onBeginGetCredentialRequest error", e) callback.onError(GetCredentialUnknownException()) } } @@ -210,9 +215,14 @@ class PasskeyProviderService : CredentialProviderService() { callback: OutcomeReceiver, ) { Log.d(javaClass.simpleName, "onBeginCreateCredentialRequest called") - processCreateCredentialRequest(request)?.let { response -> - callback.onResult(response) - } ?: let { + try { + processCreateCredentialRequest(request)?.let { response -> + callback.onResult(response) + } ?: let { + callback.onError(CreateCredentialUnknownException()) + } + } catch (e: Exception) { + Log.e(javaClass.simpleName, "onBeginCreateCredentialRequest error", e) callback.onError(CreateCredentialUnknownException()) } } From 05a39f6922c1cf924f225245dd2e3e73a49b59ca Mon Sep 17 00:00:00 2001 From: J-Jamet Date: Thu, 21 Aug 2025 20:47:12 +0200 Subject: [PATCH 019/136] fix: Show dedicated Passkey view #2097 --- .../activity/PasskeyLauncherActivity.kt | 3 +- .../passkey/PasskeyProviderService.kt | 10 +- .../passkey/util/OriginManager.kt | 6 +- .../passkey/util/PasskeyHelper.kt | 9 +- .../database/helper/LocalizedHelper.kt | 40 ++++- .../keepass/view/PasskeyTextFieldView.kt | 169 ++++++++++++++++++ .../keepass/view/PasswordTextFieldView.kt | 10 +- .../keepass/view/TemplateEditView.kt | 4 + .../kunzisoft/keepass/view/TemplateView.kt | 27 ++- .../kunzisoft/keepass/view/TextFieldView.kt | 3 +- .../res/drawable/ic_passkey_white_24dp.xml | 10 ++ app/src/main/res/values/strings.xml | 5 + app/src/main/res/values/styles.xml | 7 + .../keepass/database/element/Entry.kt | 16 +- .../keepass/database/search/SearchHelper.kt | 8 +- .../com/kunzisoft/keepass/model/EntryInfo.kt | 7 +- .../keepass/model/EntryInfoPasskey.kt | 91 ---------- .../com/kunzisoft/keepass/model/Passkey.kt | 3 +- .../keepass/model/PasskeyEntryFields.kt | 133 ++++++++++++++ .../com/kunzisoft/keepass/model/SearchInfo.kt | 20 +-- .../kunzisoft/keepass/otp/OtpEntryFields.kt | 21 +++ 21 files changed, 458 insertions(+), 144 deletions(-) create mode 100644 app/src/main/java/com/kunzisoft/keepass/view/PasskeyTextFieldView.kt create mode 100644 app/src/main/res/drawable/ic_passkey_white_24dp.xml delete mode 100644 database/src/main/java/com/kunzisoft/keepass/model/EntryInfoPasskey.kt create mode 100644 database/src/main/java/com/kunzisoft/keepass/model/PasskeyEntryFields.kt diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt index 503a9dd35..92d7db9db 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/activity/PasskeyLauncherActivity.kt @@ -56,7 +56,6 @@ import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retri import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.element.node.NodeIdUUID import com.kunzisoft.keepass.database.helper.SearchHelper -import com.kunzisoft.keepass.model.EntryInfoPasskey.getPasskey import com.kunzisoft.keepass.model.Passkey import com.kunzisoft.keepass.model.RegisterInfo import com.kunzisoft.keepass.model.SearchInfo @@ -175,7 +174,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() { val passkey = database ?.getEntryById(NodeIdUUID(nodeId)) ?.getEntryInfo(database) - ?.getPasskey() + ?.passkey ?: throw GetCredentialUnknownException("no passkey with nodeId $nodeId found") val result = Intent() diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt index 6773fa3f0..bf9409f3e 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/PasskeyProviderService.kt @@ -51,7 +51,6 @@ import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredential import com.kunzisoft.keepass.database.ContextualDatabase import com.kunzisoft.keepass.database.DatabaseTaskProvider import com.kunzisoft.keepass.database.helper.SearchHelper -import com.kunzisoft.keepass.model.EntryInfoPasskey.getPasskey import com.kunzisoft.keepass.model.SearchInfo import java.time.Instant @@ -86,7 +85,8 @@ class PasskeyProviderService : CredentialProviderService() { private fun buildPasskeySearchInfo(relyingParty: String): SearchInfo { return SearchInfo().apply { - this.relyingParty = relyingParty + this.webDomain = relyingParty + this.isAPasskeySearch = true } } @@ -144,7 +144,7 @@ class PasskeyProviderService : CredentialProviderService() { specialMode = SpecialMode.SELECTION, nodeId = passkeyEntry.id )?.let { usagePendingIntent -> - val passkey = passkeyEntry.getPasskey() + val passkey = passkeyEntry.passkey passkeyEntries.add( PublicKeyCredentialEntry( context = applicationContext, @@ -154,7 +154,7 @@ class PasskeyProviderService : CredentialProviderService() { } ?: defaultIcon, pendingIntent = usagePendingIntent, beginGetPublicKeyCredentialOption = option, - displayName = passkey?.displayName, + displayName = passkeyEntry.getVisualTitle(), isAutoSelectAllowed = true ) ) @@ -299,7 +299,7 @@ class PasskeyProviderService : CredentialProviderService() { pendingIntent = createPendingIntent, description = getString( R.string.passkey_update_description, - entryInfo.getPasskey()?.displayName + entryInfo.passkey?.displayName ) ) ) diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginManager.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginManager.kt index 65dcb7414..e8700fba1 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginManager.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/OriginManager.kt @@ -48,7 +48,7 @@ class OriginManager( clientDataHash: ByteArray? ) { val isPrivilegedApp = webOrigin != null - && webOrigin == (DEFAULT_PROTOCOL + relyingParty) && clientDataHash != null + && webOrigin == relyingParty && clientDataHash != null Log.d(TAG, "isPrivilegedApp = $isPrivilegedApp") if (!isPrivilegedApp) { AppRelyingPartyRelation.isRelationValid(relyingParty, apkSigningCertificate) @@ -57,12 +57,10 @@ class OriginManager( val origin: String get() { - return webOrigin ?: (DEFAULT_PROTOCOL + relyingParty) + return webOrigin ?: relyingParty } companion object { private val TAG = OriginManager::class.simpleName - const val DEFAULT_PROTOCOL = "https://" - } } \ No newline at end of file diff --git a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt index 1c16ff445..b14ce7a34 100644 --- a/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/credentialprovider/passkey/util/PasskeyHelper.kt @@ -51,9 +51,7 @@ import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredential import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialRequestOptions import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters import com.kunzisoft.keepass.credentialprovider.passkey.util.Base64Helper.Companion.b64Encode -import com.kunzisoft.keepass.credentialprovider.passkey.util.OriginManager.Companion.DEFAULT_PROTOCOL import com.kunzisoft.keepass.model.EntryInfo -import com.kunzisoft.keepass.model.EntryInfoPasskey.getPasskey import com.kunzisoft.keepass.model.Passkey import com.kunzisoft.keepass.model.SearchInfo import com.kunzisoft.keepass.utils.StringUtil.toHexString @@ -101,10 +99,10 @@ object PasskeyHelper { extras: Bundle? = null ) { try { - entryInfo.getPasskey()?.let { + entryInfo.passkey?.let { val mReplyIntent = Intent() Log.d(javaClass.name, "Success Passkey manual selection") - mReplyIntent.putExtra(EXTRA_PASSKEY_ELEMENT, entryInfo.getPasskey()) + mReplyIntent.putExtra(EXTRA_PASSKEY_ELEMENT, entryInfo.passkey) extras?.let { mReplyIntent.putExtras(it) } @@ -289,11 +287,10 @@ object PasskeyHelper { passkeyCreated.invoke( Passkey( username = username, - displayName = "$relyingParty (Passkey)", privateKeyPem = privateKeyPem, credentialId = b64Encode(credentialId), userHandle = b64Encode(userHandle), - relyingParty = DEFAULT_PROTOCOL + relyingParty + relyingParty = relyingParty ), PublicKeyCredentialCreationParameters( publicKeyCredentialCreationOptions = creationOptions, diff --git a/app/src/main/java/com/kunzisoft/keepass/database/helper/LocalizedHelper.kt b/app/src/main/java/com/kunzisoft/keepass/database/helper/LocalizedHelper.kt index 7ebe3aa0f..05bfa3c85 100644 --- a/app/src/main/java/com/kunzisoft/keepass/database/helper/LocalizedHelper.kt +++ b/app/src/main/java/com/kunzisoft/keepass/database/helper/LocalizedHelper.kt @@ -24,7 +24,33 @@ import com.kunzisoft.keepass.R import com.kunzisoft.keepass.database.element.database.CompressionAlgorithm import com.kunzisoft.keepass.database.element.template.TemplateEngine import com.kunzisoft.keepass.database.element.template.TemplateField -import com.kunzisoft.keepass.database.exception.* +import com.kunzisoft.keepass.database.exception.CopyEntryDatabaseException +import com.kunzisoft.keepass.database.exception.CopyGroupDatabaseException +import com.kunzisoft.keepass.database.exception.CorruptedDatabaseException +import com.kunzisoft.keepass.database.exception.DatabaseException +import com.kunzisoft.keepass.database.exception.DatabaseInputException +import com.kunzisoft.keepass.database.exception.DatabaseOutputException +import com.kunzisoft.keepass.database.exception.DuplicateUuidDatabaseException +import com.kunzisoft.keepass.database.exception.EmptyKeyDatabaseException +import com.kunzisoft.keepass.database.exception.FileNotFoundDatabaseException +import com.kunzisoft.keepass.database.exception.HardwareKeyDatabaseException +import com.kunzisoft.keepass.database.exception.InvalidAlgorithmDatabaseException +import com.kunzisoft.keepass.database.exception.InvalidCredentialsDatabaseException +import com.kunzisoft.keepass.database.exception.KDFMemoryDatabaseException +import com.kunzisoft.keepass.database.exception.MergeDatabaseKDBException +import com.kunzisoft.keepass.database.exception.MoveEntryDatabaseException +import com.kunzisoft.keepass.database.exception.MoveGroupDatabaseException +import com.kunzisoft.keepass.database.exception.NoMemoryDatabaseException +import com.kunzisoft.keepass.database.exception.SignatureDatabaseException +import com.kunzisoft.keepass.database.exception.UnknownDatabaseLocationException +import com.kunzisoft.keepass.database.exception.VersionDatabaseException +import com.kunzisoft.keepass.database.exception.XMLMalformedDatabaseException +import com.kunzisoft.keepass.model.PasskeyEntryFields.FIELD_CREDENTIAL_ID +import com.kunzisoft.keepass.model.PasskeyEntryFields.FIELD_PRIVATE_KEY +import com.kunzisoft.keepass.model.PasskeyEntryFields.FIELD_RELYING_PARTY +import com.kunzisoft.keepass.model.PasskeyEntryFields.FIELD_USERNAME +import com.kunzisoft.keepass.model.PasskeyEntryFields.FIELD_USER_HANDLE +import com.kunzisoft.keepass.model.PasskeyEntryFields.PASSKEY_FIELD fun DatabaseException.getLocalizedMessage(resources: Resources): String? = when (this) { @@ -63,6 +89,11 @@ fun TemplateField.isStandardPasswordName(context: Context, name: String): Boolea || name == getLocalizedName(context, LABEL_PASSWORD) } +fun TemplateField.isPasskeyLabel(context: Context, name: String): Boolean { + return name.equals(PASSKEY_FIELD, true) + || name == getLocalizedName(context, PASSKEY_FIELD) +} + fun TemplateField.getLocalizedName(context: Context?, name: String): String { if (context == null || TemplateEngine.containsTemplateDecorator(name) @@ -107,6 +138,13 @@ fun TemplateField.getLocalizedName(context: Context?, name: String): String { LABEL_SECURE_NOTE.equals(name, true) -> context.getString(R.string.secure_note) LABEL_MEMBERSHIP.equals(name, true) -> context.getString(R.string.membership) + PASSKEY_FIELD.equals(name, true) -> context.getString(R.string.passkey) + FIELD_USERNAME.equals(name, true) -> context.getString(R.string.passkey_username) + FIELD_PRIVATE_KEY.equals(name, true) -> context.getString(R.string.passkey_private_key) + FIELD_CREDENTIAL_ID.equals(name, true) -> context.getString(R.string.passkey_credential_id) + FIELD_USER_HANDLE.equals(name, true) -> context.getString(R.string.passkey_user_handle) + FIELD_RELYING_PARTY.equals(name, true) -> context.getString(R.string.passkey_relying_party) + else -> name } } diff --git a/app/src/main/java/com/kunzisoft/keepass/view/PasskeyTextFieldView.kt b/app/src/main/java/com/kunzisoft/keepass/view/PasskeyTextFieldView.kt new file mode 100644 index 000000000..c099f92f9 --- /dev/null +++ b/app/src/main/java/com/kunzisoft/keepass/view/PasskeyTextFieldView.kt @@ -0,0 +1,169 @@ +/* + * Copyright 2025 Jeremy Jamet / Kunzisoft. + * + * This file is part of KeePassDX. + * + * KeePassDX is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * KeePassDX is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with KeePassDX. If not, see . + * + */ +package com.kunzisoft.keepass.view + +import android.content.Context +import android.util.AttributeSet +import android.util.TypedValue +import android.view.ContextThemeWrapper +import androidx.appcompat.widget.AppCompatImageView +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.ViewCompat +import com.kunzisoft.keepass.R +import com.kunzisoft.keepass.password.PasswordEntropy + + +class PasskeyTextFieldView @JvmOverloads constructor(context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0) + : PasswordTextFieldView(context, attrs, defStyle) { + + private var relyingPartyViewId = ViewCompat.generateViewId() + private var usernameViewId = ViewCompat.generateViewId() + private var passkeyImageId = ViewCompat.generateViewId() + + private var passkeyImage = AppCompatImageView( + ContextThemeWrapper(context, R.style.KeepassDXStyle_ImageButton_Simple), null, 0).apply { + layoutParams = LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT) + setImageDrawable(ContextCompat.getDrawable(context, R.drawable.ic_passkey_white_24dp)) + contentDescription = context.getString(R.string.passkey) + } + + private val relyingPartyView = AppCompatTextView(context).apply { + setTextAppearance(context, + R.style.KeepassDXStyle_TextAppearance_TextNodePrimary) + layoutParams = LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT + ).also { + it.topMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8f, + resources.displayMetrics + ).toInt() + it.leftMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8f, + resources.displayMetrics + ).toInt() + it.marginStart = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8f, + resources.displayMetrics + ).toInt() + } + setTextIsSelectable(true) + } + private val usernameView = AppCompatTextView(context).apply { + setTextAppearance(context, + R.style.KeepassDXStyle_TextAppearance_TextNodeSecondary) + layoutParams = LayoutParams( + LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT).also { + it.topMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 4f, + resources.displayMetrics + ).toInt() + it.leftMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8f, + resources.displayMetrics + ).toInt() + it.marginStart = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 8f, + resources.displayMetrics + ).toInt() + } + setTextIsSelectable(true) + } + + private fun buildViews() { + indicatorDrawable?.let { + DrawableCompat.setTint(it, PasswordEntropy.Strength.VERY_UNGUESSABLE.color) + } + passkeyImage.apply { + id = passkeyImageId + layoutParams = (layoutParams as LayoutParams?)?.also { + it.addRule(ALIGN_PARENT_RIGHT) + it.addRule(ALIGN_PARENT_END) + } + } + labelView.apply { + layoutParams = (layoutParams as LayoutParams?)?.also { + it.addRule(LEFT_OF, passkeyImageId) + it.addRule(START_OF, passkeyImageId) + } + } + relyingPartyView.apply { + id = relyingPartyViewId + layoutParams = (layoutParams as LayoutParams?)?.also { + it.addRule(LEFT_OF, passkeyImageId) + it.addRule(START_OF, passkeyImageId) + it.addRule(BELOW, labelViewId) + } + } + usernameView.apply { + id = usernameViewId + layoutParams = (layoutParams as LayoutParams?)?.also { + it.addRule(LEFT_OF, passkeyImageId) + it.addRule(START_OF, passkeyImageId) + it.addRule(BELOW, relyingPartyViewId) + } + } + } + + init { + removeAllViews() + buildViews() + addView(passkeyImage) + addView(labelView) + addView(relyingPartyView) + addView(usernameView) + } + + override var default: String = "" + override var isFieldVisible: Boolean = true + + var relyingParty: String + get() { + return relyingPartyView.text.toString() + } + set(value) { + relyingPartyView.text = value + } + + var username: String + get() { + return usernameView.text.toString() + } + set(value) { + usernameView.text = value + } + + override fun getEntropyStrength(passwordText: String) { + // Do nothing + } +} diff --git a/app/src/main/java/com/kunzisoft/keepass/view/PasswordTextFieldView.kt b/app/src/main/java/com/kunzisoft/keepass/view/PasswordTextFieldView.kt index c56454d10..12942811e 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/PasswordTextFieldView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/PasswordTextFieldView.kt @@ -34,9 +34,9 @@ import com.kunzisoft.keepass.password.PasswordGenerator import com.kunzisoft.keepass.settings.PreferencesUtil -class PasswordTextFieldView @JvmOverloads constructor(context: Context, - attrs: AttributeSet? = null, - defStyle: Int = 0) +open class PasswordTextFieldView @JvmOverloads constructor(context: Context, + attrs: AttributeSet? = null, + defStyle: Int = 0) : TextFieldView(context, attrs, defStyle) { private var mPasswordEntropyCalculator: PasswordEntropy = PasswordEntropy { @@ -45,7 +45,7 @@ class PasswordTextFieldView @JvmOverloads constructor(context: Context, } } - private var indicatorDrawable = ContextCompat.getDrawable( + protected var indicatorDrawable = ContextCompat.getDrawable( context, R.drawable.ic_shield_white_24dp )?.apply { @@ -98,7 +98,7 @@ class PasswordTextFieldView @JvmOverloads constructor(context: Context, value = resources.getString(valueId) } - private fun getEntropyStrength(passwordText: String) { + protected open fun getEntropyStrength(passwordText: String) { mPasswordEntropyCalculator.getEntropyStrength(passwordText) { entropyStrength -> labelView.apply { post { diff --git a/app/src/main/java/com/kunzisoft/keepass/view/TemplateEditView.kt b/app/src/main/java/com/kunzisoft/keepass/view/TemplateEditView.kt index c6909237c..d1fcb0940 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/TemplateEditView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/TemplateEditView.kt @@ -20,6 +20,7 @@ import com.kunzisoft.keepass.database.helper.getLocalizedName import com.kunzisoft.keepass.database.helper.isStandardPasswordName import com.kunzisoft.keepass.model.DataDate import com.kunzisoft.keepass.model.DataTime +import com.kunzisoft.keepass.model.PasskeyEntryFields import com.kunzisoft.keepass.otp.OtpEntryFields @@ -261,6 +262,9 @@ class TemplateEditView @JvmOverloads constructor(context: Context, mEntryInfo?.otpModel = OtpEntryFields.parseFields { key -> getCustomField(key).protectedValue.toString() }?.otpModel + mEntryInfo?.passkey = PasskeyEntryFields.parseFields { key -> + getCustomField(key).protectedValue.toString() + } } override fun onRestoreEntryInstanceState(state: SavedState) { diff --git a/app/src/main/java/com/kunzisoft/keepass/view/TemplateView.kt b/app/src/main/java/com/kunzisoft/keepass/view/TemplateView.kt index d14cc137d..b56891c63 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/TemplateView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/TemplateView.kt @@ -11,8 +11,11 @@ import com.kunzisoft.keepass.database.element.security.ProtectedString import com.kunzisoft.keepass.database.element.template.TemplateAttribute import com.kunzisoft.keepass.database.element.template.TemplateField import com.kunzisoft.keepass.database.helper.getLocalizedName +import com.kunzisoft.keepass.database.helper.isPasskeyLabel import com.kunzisoft.keepass.database.helper.isStandardPasswordName import com.kunzisoft.keepass.model.OtpModel +import com.kunzisoft.keepass.model.Passkey +import com.kunzisoft.keepass.model.PasskeyEntryFields.PASSKEY_FIELD import com.kunzisoft.keepass.otp.OtpElement import com.kunzisoft.keepass.otp.OtpEntryFields.OTP_TOKEN_FIELD @@ -52,6 +55,8 @@ class TemplateView @JvmOverloads constructor(context: Context, return context?.let { (if (TemplateField.isStandardPasswordName(context, templateAttribute.label)) PasswordTextFieldView(it) + else if (TemplateField.isPasskeyLabel(context, templateAttribute.label)) + PasskeyTextFieldView(it) else TextFieldView(it)).apply { applyFontVisibility(mFontInVisibility) setProtection(field.protectedValue.isProtected, mHideProtectedValue) @@ -125,20 +130,20 @@ class TemplateView @JvmOverloads constructor(context: Context, override fun populateViewsWithEntryInfo(showEmptyFields: Boolean): List { val emptyCustomFields = super.populateViewsWithEntryInfo(false) - // Hide empty custom fields emptyCustomFields.forEach { customFieldId -> customFieldId.view.isVisible = false } - removeOtpRunnable() mEntryInfo?.let { entryInfo -> // Assign specific OTP dynamic view entryInfo.otpModel?.let { assignOtp(it) } + entryInfo.passkey?.let { + assignPasskey(it) + } } - return emptyCustomFields } @@ -200,6 +205,22 @@ class TemplateView @JvmOverloads constructor(context: Context, } } + private fun getPasskeyView(): PasskeyTextFieldView? { + getViewFieldByName(PASSKEY_FIELD)?.let { viewField -> + val view = viewField.view + if (view is PasskeyTextFieldView) + return view + } + return null + } + + private fun assignPasskey(passkey: Passkey) { + getPasskeyView()?.apply { + relyingParty = passkey.relyingParty + username = passkey.username + } + } + private fun removeOtpRunnable() { mLastOtpTokenView?.removeCallbacks(mOtpRunnable) mLastOtpTokenView = null diff --git a/app/src/main/java/com/kunzisoft/keepass/view/TextFieldView.kt b/app/src/main/java/com/kunzisoft/keepass/view/TextFieldView.kt index 6077aa7e5..064a882ef 100644 --- a/app/src/main/java/com/kunzisoft/keepass/view/TextFieldView.kt +++ b/app/src/main/java/com/kunzisoft/keepass/view/TextFieldView.kt @@ -27,7 +27,6 @@ import android.util.AttributeSet import android.util.TypedValue import android.view.ContextThemeWrapper import android.view.View -import android.view.View.OnClickListener import android.widget.RelativeLayout import androidx.annotation.StringRes import androidx.appcompat.widget.AppCompatImageButton @@ -46,7 +45,7 @@ open class TextFieldView @JvmOverloads constructor(context: Context, defStyle: Int = 0) : RelativeLayout(context, attrs, defStyle), GenericTextFieldView { - private var labelViewId = ViewCompat.generateViewId() + protected var labelViewId = ViewCompat.generateViewId() private var valueViewId = ViewCompat.generateViewId() private var showButtonId = ViewCompat.generateViewId() private var copyButtonId = ViewCompat.generateViewId() diff --git a/app/src/main/res/drawable/ic_passkey_white_24dp.xml b/app/src/main/res/drawable/ic_passkey_white_24dp.xml new file mode 100644 index 000000000..4d0fabc83 --- /dev/null +++ b/app/src/main/res/drawable/ic_passkey_white_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec231268d..4ef7e4ec3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -746,4 +746,9 @@ Select an existing passkey KeePassDX Database Locked Select to unlock + Passkey Username + Passkey Private Key + Passkey Credential Id + Passkey User Handle + Passkey Relying Party \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 12cb2aacf..0f87e9f95 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -567,6 +567,13 @@ 8dp 16sp + + + + + + + -