From 3a8f3aa0f622194033a913464931e340ddfe1c95 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Thu, 6 Jun 2024 11:36:44 -0400 Subject: [PATCH] [PM-8137] Passkey creation navigation and account switching (#1380) --- .../res/xml/provider.xml | 0 .../assets/fido2_privileged_allow_list.json | 481 ++++++++++++++++++ .../java/com/x8bit/bitwarden/MainViewModel.kt | 20 +- .../network/api/DigitalAssetLinkApi.kt | 19 + .../network/di/Fido2NetworkModule.kt | 33 ++ .../model/DigitalAssetLinkResponseJson.kt | 32 ++ .../PublicKeyCredentialCreationOptions.kt | 113 ++++ .../service/DigitalAssetLinkService.kt | 17 + .../service/DigitalAssetLinkServiceImpl.kt | 21 + .../autofill/fido2/di/Fido2ProviderModule.kt | 20 +- .../fido2/manager/Fido2CredentialManager.kt | 28 + .../manager/Fido2CredentialManagerImpl.kt | 149 ++++++ .../model/Fido2CreateCredentialResult.kt | 23 + .../fido2/model/Fido2CredentialRequest.kt | 30 ++ .../fido2/model/Fido2ValidateOriginResult.kt | 55 ++ .../autofill/fido2/util/Fido2IntentUtils.kt | 38 ++ .../data/platform/manager/AssetManager.kt | 13 + .../data/platform/manager/AssetManagerImpl.kt | 26 + .../manager/di/PlatformManagerModule.kt | 12 + .../manager/model/SpecialCircumstance.kt | 10 + .../util/SpecialCircumstanceExtensions.kt | 2 + .../data/platform/util/AndroidBuildUtils.kt | 13 + .../platform/util/CallingAppInfoExtensions.kt | 60 +++ .../fido2/manager/Fido2CompletionManager.kt | 14 + .../manager/Fido2CompletionManagerImpl.kt | 47 ++ .../ui/platform/base/util/StringExtensions.kt | 12 + .../composition/LocalManagerProvider.kt | 8 + .../platform/feature/rootnav/RootNavScreen.kt | 10 + .../feature/rootnav/RootNavViewModel.kt | 22 + .../manager/intent/IntentManagerImpl.kt | 2 +- .../itemlisting/VaultItemListingScreen.kt | 23 + .../itemlisting/VaultItemListingViewModel.kt | 222 +++++++- .../util/VaultItemListingDataExtensions.kt | 8 + .../com/x8bit/bitwarden/MainViewModelTest.kt | 147 +++++- .../service/DigitalAssetLinkServiceTest.kt | 67 +++ .../manager/Fido2CredentialManagerTest.kt | 331 ++++++++++++ .../data/platform/base/BaseServiceTest.kt | 5 +- .../util/CallingAppInfoExtensionsTest.kt | 185 +++++++ .../feature/rootnav/RootNavViewModelTest.kt | 46 ++ .../itemlisting/VaultItemListingScreenTest.kt | 7 + .../VaultItemListingViewModelTest.kt | 334 ++++++++++++ .../VaultItemListingDataExtensionsTest.kt | 8 + .../util/VaultItemListingDataUtil.kt | 6 + 43 files changed, 2690 insertions(+), 29 deletions(-) rename app/src/{standardDebug => debug}/res/xml/provider.xml (100%) create mode 100644 app/src/main/assets/fido2_privileged_allow_list.json create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/api/DigitalAssetLinkApi.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/di/Fido2NetworkModule.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/model/DigitalAssetLinkResponseJson.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/model/PublicKeyCredentialCreationOptions.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/service/DigitalAssetLinkService.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/service/DigitalAssetLinkServiceImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CreateCredentialResult.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialRequest.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2ValidateOriginResult.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/util/Fido2IntentUtils.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AssetManager.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AssetManagerImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/util/AndroidBuildUtils.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManager.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManagerImpl.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/service/DigitalAssetLinkServiceTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt diff --git a/app/src/standardDebug/res/xml/provider.xml b/app/src/debug/res/xml/provider.xml similarity index 100% rename from app/src/standardDebug/res/xml/provider.xml rename to app/src/debug/res/xml/provider.xml diff --git a/app/src/main/assets/fido2_privileged_allow_list.json b/app/src/main/assets/fido2_privileged_allow_list.json new file mode 100644 index 000000000..49236238e --- /dev/null +++ b/app/src/main/assets/fido2_privileged_allow_list.json @@ -0,0 +1,481 @@ +{ + "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" + } + ] + } + } + ] +} + diff --git a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt index 7552e48cd..b1fb494d9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/MainViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import com.bitwarden.core.CipherView import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull +import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CredentialRequestOrNull import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull @@ -37,16 +38,16 @@ private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance" /** * A view model that helps launch actions for the [MainActivity]. */ -@Suppress("LongParameterList") +@Suppress("LongParameterList", "TooManyFunctions") @HiltViewModel class MainViewModel @Inject constructor( autofillSelectionManager: AutofillSelectionManager, private val specialCircumstanceManager: SpecialCircumstanceManager, private val garbageCollectionManager: GarbageCollectionManager, private val intentManager: IntentManager, - authRepository: AuthRepository, settingsRepository: SettingsRepository, vaultRepository: VaultRepository, + private val authRepository: AuthRepository, private val savedStateHandle: SavedStateHandle, ) : BaseViewModel( MainState( @@ -172,6 +173,7 @@ class MainViewModel @Inject constructor( val shareData = intentManager.getShareDataFromIntent(intent) val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut val hasVaultShortcut = intent.isMyVaultShortcut + val fido2CredentialRequestData = intent.getFido2CredentialRequestOrNull() when { passwordlessRequestData != null -> { specialCircumstanceManager.specialCircumstance = @@ -210,6 +212,20 @@ class MainViewModel @Inject constructor( ) } + fido2CredentialRequestData != null -> { + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.Fido2Save( + fido2CredentialRequest = fido2CredentialRequestData, + ) + + // Switch accounts if the selected user is not the active user. + if (authRepository.activeUserId != null && + authRepository.activeUserId != fido2CredentialRequestData.userId + ) { + authRepository.switchAccount(fido2CredentialRequestData.userId) + } + } + hasGeneratorShortcut -> { specialCircumstanceManager.specialCircumstance = SpecialCircumstance.GeneratorShortcut diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/api/DigitalAssetLinkApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/api/DigitalAssetLinkApi.kt new file mode 100644 index 000000000..594b5b236 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/api/DigitalAssetLinkApi.kt @@ -0,0 +1,19 @@ +package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.api + +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson +import retrofit2.http.GET +import retrofit2.http.Url + +/** + * Defines calls to an RP digital asset link file. + */ +interface DigitalAssetLinkApi { + + /** + * Attempts to download the asset links file from the RP. + */ + @GET + suspend fun getDigitalAssetLinks( + @Url url: String, + ): Result> +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/di/Fido2NetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/di/Fido2NetworkModule.kt new file mode 100644 index 000000000..8b50d30b3 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/di/Fido2NetworkModule.kt @@ -0,0 +1,33 @@ +package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.di + +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkServiceImpl +import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.Retrofits +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.create +import javax.inject.Singleton + +/** + * Provides network dependencies in the fido2 package. + */ +@Module +@InstallIn(SingletonComponent::class) +object Fido2NetworkModule { + + @Provides + @Singleton + fun provideDigitalAssetLinkService( + retrofits: Retrofits, + ): DigitalAssetLinkService = + DigitalAssetLinkServiceImpl( + digitalAssetLinkApi = retrofits + .staticRetrofitBuilder + // This URL will be overridden dynamically. + .baseUrl("https://www.bitwarden.com") + .build() + .create(), + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/model/DigitalAssetLinkResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/model/DigitalAssetLinkResponseJson.kt new file mode 100644 index 000000000..2c504917c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/model/DigitalAssetLinkResponseJson.kt @@ -0,0 +1,32 @@ +package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Models a response from an RP digital asset link request. + */ +@Serializable +data class DigitalAssetLinkResponseJson( + @SerialName("relation") + val relation: List, + + @SerialName("target") + val target: Target, +) { + + /** + * Represents targets for an asset link statement. + */ + @Serializable + data class Target( + @SerialName("namespace") + val namespace: String, + + @SerialName("package_name") + val packageName: String?, + + @SerialName("sha256_cert_fingerprints") + val sha256CertFingerprints: List?, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/model/PublicKeyCredentialCreationOptions.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/model/PublicKeyCredentialCreationOptions.kt new file mode 100644 index 000000000..f06e2c857 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/model/PublicKeyCredentialCreationOptions.kt @@ -0,0 +1,113 @@ +package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Models a FIDO 2 credential creation request options received from a Relying Party (RP). + */ +@Serializable +data class PublicKeyCredentialCreationOptions( + @SerialName("authenticatorSelection") + val authenticatorSelection: AuthenticatorSelectionCriteria, + @SerialName("challenge") + val challenge: String, + @SerialName("excludedCredentials") + val excludeCredentials: List = emptyList(), + @SerialName("pubKeyCredParams") + val pubKeyCredParams: List, + @SerialName("rp") + val relyingParty: PublicKeyCredentialRpEntity, + @SerialName("user") + val user: PublicKeyCredentialUserEntity, +) { + + /** + * Represents criteria that must be respected when selecting a credential. + */ + @Serializable + data class AuthenticatorSelectionCriteria( + @SerialName("authenticatorAttachment") + val authenticatorAttachment: AuthenticatorAttachment? = null, + @SerialName("residentKey") + val residentKey: ResidentKeyRequirement? = null, + ) { + /** + * Enum class representing the types of attachments associated with selection criteria. + */ + @Serializable + enum class AuthenticatorAttachment { + @SerialName("platform") + PLATFORM, + + @SerialName("cross_platform") + CROSS_PLATFORM, + } + + /** + * Enum class indicating the type of authentication expected by the selection criteria. + */ + @Serializable + enum class ResidentKeyRequirement { + /** + * User verification is preferred during selection, if supported. + */ + @SerialName("preferred") + PREFERRED, + + /** + * User verification is required during selection. + */ + @SerialName("required") + REQUIRED, + } + } + + /** + * Represents details about a credential provided in the creation options. + */ + @Serializable + data class PublicKeyCredentialDescriptor( + @SerialName("type") + val type: String, + @SerialName("id") + val id: String, + @SerialName("transports") + val transports: List, + ) + + /** + * Represents parameters for a credential in the creation options. + */ + @Serializable + data class PublicKeyCredentialParameters( + @SerialName("type") + val type: String, + @SerialName("alg") + val alg: Long, + ) + + /** + * Represents the RP associated with the credential options. + */ + @Serializable + data class PublicKeyCredentialRpEntity( + @SerialName("name") + val name: String, + @SerialName("id") + val id: String, + ) + + /** + * Represents the user associated with teh credential options. + */ + @Serializable + data class PublicKeyCredentialUserEntity( + @SerialName("name") + val name: String, + @SerialName("id") + val id: String, + @SerialName("displayName") + val displayName: String, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/service/DigitalAssetLinkService.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/service/DigitalAssetLinkService.kt new file mode 100644 index 000000000..5df75f50e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/service/DigitalAssetLinkService.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service + +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson + +/** + * Provides an API for querying digital asset links. + */ +interface DigitalAssetLinkService { + + /** + * Attempt to retrieve the asset links file from the provided [relyingParty]. + */ + suspend fun getDigitalAssetLinkForRp( + scheme: String = "https://", + relyingParty: String, + ): Result> +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/service/DigitalAssetLinkServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/service/DigitalAssetLinkServiceImpl.kt new file mode 100644 index 000000000..4a48c5871 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/service/DigitalAssetLinkServiceImpl.kt @@ -0,0 +1,21 @@ +package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service + +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.api.DigitalAssetLinkApi +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson + +/** + * Primary implementation of [DigitalAssetLinkService]. + */ +class DigitalAssetLinkServiceImpl( + private val digitalAssetLinkApi: DigitalAssetLinkApi, +) : DigitalAssetLinkService { + + override suspend fun getDigitalAssetLinkForRp( + scheme: String, + relyingParty: String, + ): Result> = + digitalAssetLinkApi + .getDigitalAssetLinks( + url = "$scheme$relyingParty/.well-known/assetlinks.json", + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt index 8d095981a..3c4faff3b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/di/Fido2ProviderModule.kt @@ -4,9 +4,13 @@ import android.content.Context import android.os.Build import androidx.annotation.RequiresApi import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService +import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager +import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManagerImpl import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessor import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessorImpl import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import com.x8bit.bitwarden.data.platform.manager.AssetManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import dagger.Module @@ -14,6 +18,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json import javax.inject.Singleton /** @@ -25,8 +30,8 @@ import javax.inject.Singleton object Fido2ProviderModule { @RequiresApi(Build.VERSION_CODES.S) - @Singleton @Provides + @Singleton fun provideCredentialProviderProcessor( @ApplicationContext context: Context, authRepository: AuthRepository, @@ -39,4 +44,17 @@ object Fido2ProviderModule { intentManager, dispatcherManager, ) + + @Provides + @Singleton + fun provideFido2CredentialManager( + assetManager: AssetManager, + digitalAssetLinkService: DigitalAssetLinkService, + json: Json, + ): Fido2CredentialManager = + Fido2CredentialManagerImpl( + assetManager = assetManager, + digitalAssetLinkService = digitalAssetLinkService, + json = json, + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt new file mode 100644 index 000000000..305e4f26e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManager.kt @@ -0,0 +1,28 @@ +package com.x8bit.bitwarden.data.autofill.fido2.manager + +import com.bitwarden.core.CipherView +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult + +/** + * Responsible for managing FIDO 2 credential creation and authentication. + */ +interface Fido2CredentialManager { + + /** + * Attempt to validate the RP and origin of the provided [fido2CredentialRequest]. + */ + suspend fun validateOrigin( + fido2CredentialRequest: Fido2CredentialRequest, + ): Fido2ValidateOriginResult + + /** + * Attempt to create a FIDO2 credential from the given [credentialRequest] and associate it to + * the given [cipherView]. + */ + fun createCredentialForCipher( + credentialRequest: Fido2CredentialRequest, + cipherView: CipherView, + ): Fido2CreateCredentialResult +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt new file mode 100644 index 000000000..39a079ccd --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerImpl.kt @@ -0,0 +1,149 @@ +package com.x8bit.bitwarden.data.autofill.fido2.manager + +import androidx.credentials.exceptions.CreateCredentialUnknownException +import androidx.credentials.provider.CallingAppInfo +import com.bitwarden.core.CipherView +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.PublicKeyCredentialCreationOptions +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult +import com.x8bit.bitwarden.data.platform.manager.AssetManager +import com.x8bit.bitwarden.data.platform.util.asFailure +import com.x8bit.bitwarden.data.platform.util.asSuccess +import com.x8bit.bitwarden.data.platform.util.flatMap +import com.x8bit.bitwarden.data.platform.util.getCallingAppApkFingerprint +import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + +private const val ALLOW_LIST_FILE_NAME = "fido2_privileged_allow_list.json" + +/** + * Primary implementation of [Fido2CredentialManager]. + */ +class Fido2CredentialManagerImpl( + private val assetManager: AssetManager, + private val digitalAssetLinkService: DigitalAssetLinkService, + private val json: Json, +) : Fido2CredentialManager { + + override suspend fun validateOrigin( + fido2CredentialRequest: Fido2CredentialRequest, + ): Fido2ValidateOriginResult { + val callingAppInfo = fido2CredentialRequest.callingAppInfo + return if (callingAppInfo.isOriginPopulated()) { + validatePrivilegedAppOrigin(callingAppInfo) + } else { + validateCallingApplicationAssetLinks(fido2CredentialRequest) + } + } + + @Suppress("ReturnCount") + private suspend fun validateCallingApplicationAssetLinks( + fido2CredentialRequest: Fido2CredentialRequest, + ): Fido2ValidateOriginResult { + val callingAppInfo = fido2CredentialRequest.callingAppInfo + return fido2CredentialRequest + .requestJson + .getRpId(json) + .flatMap { rpId -> + digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = rpId) + } + .onFailure { + return Fido2ValidateOriginResult.Error.AssetLinkNotFound + } + .map { statements -> + statements + .filterMatchingAppStatementsOrNull( + rpPackageName = callingAppInfo.packageName, + ) + ?: return Fido2ValidateOriginResult.Error.ApplicationNotFound + } + .map { matchingStatements -> + matchingStatements + .filterMatchingAppSignaturesOrNull( + signature = callingAppInfo.getCallingAppApkFingerprint(), + ) + ?: return Fido2ValidateOriginResult.Error.ApplicationNotVerified + } + .fold( + onSuccess = { + Fido2ValidateOriginResult.Success + }, + onFailure = { + Fido2ValidateOriginResult.Error.Unknown + }, + ) + } + + private suspend fun validatePrivilegedAppOrigin( + callingAppInfo: CallingAppInfo, + ): Fido2ValidateOriginResult = + assetManager + .readAsset(ALLOW_LIST_FILE_NAME) + .map { allowList -> + callingAppInfo.validatePrivilegedApp( + allowList = allowList, + ) + } + .fold( + onSuccess = { it }, + onFailure = { Fido2ValidateOriginResult.Error.Unknown }, + ) + + override fun createCredentialForCipher( + credentialRequest: Fido2CredentialRequest, + cipherView: CipherView, + ): Fido2CreateCredentialResult { + // TODO [PM-8137]: Create and save passkey to cipher. + return Fido2CreateCredentialResult.Error(CreateCredentialUnknownException()) + } + + /** + * Returns statements targeting the calling Android application, or null. + */ + private fun List.filterMatchingAppStatementsOrNull( + rpPackageName: String, + ): List? = + filter { statement -> + val target = statement.target + target.namespace == "android_app" && + target.packageName == rpPackageName && + statement.relation.containsAll( + listOf( + "delegate_permission/common.get_login_creds", + "delegate_permission/common.handle_all_urls", + ), + ) + } + .takeUnless { it.isEmpty() } + + /** + * Returns statements that match the given [signature], or null. + */ + private fun List.filterMatchingAppSignaturesOrNull( + signature: String, + ): List? = + filter { statement -> + statement.target.sha256CertFingerprints + ?.contains(signature) + ?: false + } + .takeUnless { it.isEmpty() } + + private fun String.getRpId(json: Json): Result { + return try { + json + .decodeFromString(this) + .relyingParty + .id + .asSuccess() + } catch (e: SerializationException) { + e.asFailure() + } catch (e: IllegalArgumentException) { + e.asFailure() + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CreateCredentialResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CreateCredentialResult.kt new file mode 100644 index 000000000..00795c02b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CreateCredentialResult.kt @@ -0,0 +1,23 @@ +package com.x8bit.bitwarden.data.autofill.fido2.model + +import androidx.credentials.exceptions.CreateCredentialException + +/** + * Models the data returned from creating a FIDO 2 credential. + */ +sealed class Fido2CreateCredentialResult { + + /** + * Models a successful response for creating a credential. + */ + data class Success( + val registrationResponse: String, + ) : Fido2CreateCredentialResult() + + /** + * Models an error response for creating a credential. + */ + data class Error( + val exception: CreateCredentialException, + ) : Fido2CreateCredentialResult() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialRequest.kt new file mode 100644 index 000000000..7afb56d53 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2CredentialRequest.kt @@ -0,0 +1,30 @@ +package com.x8bit.bitwarden.data.autofill.fido2.model + +import android.content.pm.SigningInfo +import android.os.Parcelable +import androidx.credentials.provider.CallingAppInfo +import kotlinx.parcelize.Parcelize + +/** + * Represents raw data from the a user deciding to create a passkey in their vault via the + * credential manager framework. + * + * @property userId The user under which the passkey should be saved. + * @property requestJson JSON payload containing the RP request. + * @property callingAppInfo Information about the application that initiated the request. + */ +@Parcelize +data class Fido2CredentialRequest( + val userId: String, + val requestJson: String, + val packageName: String, + val signingInfo: SigningInfo, + val origin: String?, +) : Parcelable { + val callingAppInfo: CallingAppInfo + get() = CallingAppInfo( + packageName = packageName, + signingInfo = signingInfo, + origin = origin, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2ValidateOriginResult.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2ValidateOriginResult.kt new file mode 100644 index 000000000..55df7e171 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/model/Fido2ValidateOriginResult.kt @@ -0,0 +1,55 @@ +package com.x8bit.bitwarden.data.autofill.fido2.model + +/** + * Models the result of validating the origin of a FIDO2 request. + */ +sealed class Fido2ValidateOriginResult { + + /** + * Represents a successful origin validation. + */ + data object Success : Fido2ValidateOriginResult() + + /** + * Represents a validation error. + */ + sealed class Error : Fido2ValidateOriginResult() { + + /** + * Indicates the digital asset links file could not be located. + */ + data object AssetLinkNotFound : Error() + + /** + * Indicates the application package name was not found in the digital asset links file. + */ + data object ApplicationNotFound : Error() + + /** + * Indicates the application fingerprint was not found the digital asset links file. + */ + data object ApplicationNotVerified : Error() + + /** + * Indicates the calling application is privileged but its package name is not found within + * the privileged app allow list. + */ + data object PrivilegedAppNotAllowed : Error() + + /** + * Indicates the calling app is privileged but but no matching signing certificate signature + * is present in the allow list. + */ + data object PrivilegedAppSignatureNotFound : Error() + + /** + * Indicates passkeys are not supported for the requesting application. + */ + data object PasskeyNotSupportedForApp : Error() + + /** + * Indicates an unknown error was encountered while validating the origin. + */ + data object Unknown : Error() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/util/Fido2IntentUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/util/Fido2IntentUtils.kt new file mode 100644 index 000000000..6b5c1c469 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/autofill/fido2/util/Fido2IntentUtils.kt @@ -0,0 +1,38 @@ +package com.x8bit.bitwarden.data.autofill.fido2.util + +import android.content.Intent +import android.os.Build +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.provider.PendingIntentHandler +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow +import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_USER_ID + +/** + * Checks if this [Intent] contains a [Fido2CredentialRequest] related to an ongoing FIDO 2 + * credential creation process. + */ +@Suppress("ReturnCount") +@OmitFromCoverage +fun Intent.getFido2CredentialRequestOrNull(): Fido2CredentialRequest? { + if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) return null + + val systemRequest = PendingIntentHandler.retrieveProviderCreateCredentialRequest(this) + ?: return null + + val createPublicKeyRequest = + systemRequest.callingRequest as? CreatePublicKeyCredentialRequest + ?: return null + + val userId = getStringExtra(EXTRA_KEY_USER_ID) + ?: return null + + return Fido2CredentialRequest( + userId = userId, + requestJson = createPublicKeyRequest.requestJson, + packageName = systemRequest.callingAppInfo.packageName, + signingInfo = systemRequest.callingAppInfo.signingInfo, + origin = systemRequest.callingAppInfo.origin, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AssetManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AssetManager.kt new file mode 100644 index 000000000..5a57340ff --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AssetManager.kt @@ -0,0 +1,13 @@ +package com.x8bit.bitwarden.data.platform.manager + +/** + * Manages reading assets. + */ +interface AssetManager { + + /** + * Read [fileName] from the assets folder. A successful result will contain the contents as a + * String. + */ + suspend fun readAsset(fileName: String): Result +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AssetManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AssetManagerImpl.kt new file mode 100644 index 000000000..7dd45187e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/AssetManagerImpl.kt @@ -0,0 +1,26 @@ +package com.x8bit.bitwarden.data.platform.manager + +import android.content.Context +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage +import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import kotlinx.coroutines.withContext + +/** + * Primary implementation of [AssetManager]. + */ +@OmitFromCoverage +class AssetManagerImpl( + private val context: Context, + private val dispatcherManager: DispatcherManager, +) : AssetManager { + + override suspend fun readAsset(fileName: String): Result = runCatching { + withContext(dispatcherManager.io) { + context + .assets + .open(fileName) + .bufferedReader() + .use { it.readText() } + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt index 02fa8db46..93daac961 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PlatformManagerModule.kt @@ -13,6 +13,8 @@ import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlI import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService import com.x8bit.bitwarden.data.platform.manager.AppForegroundManager import com.x8bit.bitwarden.data.platform.manager.AppForegroundManagerImpl +import com.x8bit.bitwarden.data.platform.manager.AssetManager +import com.x8bit.bitwarden.data.platform.manager.AssetManagerImpl import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManagerImpl import com.x8bit.bitwarden.data.platform.manager.CrashLogsManager @@ -177,4 +179,14 @@ object PlatformManagerModule { settingsRepository = settingsRepository, legacyAppCenterMigrator = legacyAppCenterMigrator, ) + + @Provides + @Singleton + fun provideAssetManager( + @ApplicationContext context: Context, + dispatcherManager: DispatcherManager, + ): AssetManager = AssetManagerImpl( + context = context, + dispatcherManager = dispatcherManager, + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt index 598adf39f..a112956b6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/model/SpecialCircumstance.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.platform.manager.model import android.os.Parcelable +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager @@ -47,6 +48,15 @@ sealed class SpecialCircumstance : Parcelable { val shouldFinishWhenComplete: Boolean, ) : SpecialCircumstance() + /** + * The app was launched via the credential manager framework in order to allow the user to + * manually save a passkey to their vault. + */ + @Parcelize + data class Fido2Save( + val fido2CredentialRequest: Fido2CredentialRequest, + ) : SpecialCircumstance() + /** * The app was launched via deeplink to the generator. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt index 23d1c88ce..79b6406c9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/util/SpecialCircumstanceExtensions.kt @@ -15,6 +15,7 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? = is SpecialCircumstance.ShareNewSend -> null SpecialCircumstance.GeneratorShortcut -> null SpecialCircumstance.VaultShortcut -> null + is SpecialCircumstance.Fido2Save -> null } /** @@ -28,4 +29,5 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData? is SpecialCircumstance.ShareNewSend -> null SpecialCircumstance.GeneratorShortcut -> null SpecialCircumstance.VaultShortcut -> null + is SpecialCircumstance.Fido2Save -> null } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/AndroidBuildUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/AndroidBuildUtils.kt new file mode 100644 index 000000000..cc48b4555 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/AndroidBuildUtils.kt @@ -0,0 +1,13 @@ +@file:OmitFromCoverage + +package com.x8bit.bitwarden.data.platform.util + +import android.os.Build +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage + +/** + * Returns true if the current OS build version is below the provided [version]. + * + * @see Build.VERSION_CODES + */ +fun isBuildVersionBelow(version: Int): Boolean = version > Build.VERSION.SDK_INT diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt new file mode 100644 index 000000000..318ff4cca --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensions.kt @@ -0,0 +1,60 @@ +package com.x8bit.bitwarden.data.platform.util + +import androidx.credentials.provider.CallingAppInfo +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult +import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull +import java.security.MessageDigest + +/** + * Returns the name of the RP. If this [CallingAppInfo] is a privileged app the RP host name will be + * returned. If this [CallingAppInfo] is a native RP application the package name will be returned. + * Otherwise, `null` is returned. + */ +fun CallingAppInfo.getFido2RpOrNull(): String? { + return if (isOriginPopulated()) { + origin?.toHostOrPathOrNull() + } else { + packageName + } +} + +/** + * Returns the signing certificate hash formatted as a hex string. + */ +@OptIn(ExperimentalStdlibApi::class) +fun CallingAppInfo.getCallingAppApkFingerprint(): String { + val cert = signingInfo.apkContentsSigners[0].toByteArray() + val md = MessageDigest.getInstance("SHA-256") + val certHash = md.digest(cert) + return certHash + .joinToString(":") { b -> + b.toHexString(HexFormat.UpperCase) + } +} + +/** + * Returns true if this [CallingAppInfo] is present in the privileged app [allowList]. Otherwise, + * returns false. + */ +fun CallingAppInfo.validatePrivilegedApp(allowList: String): Fido2ValidateOriginResult { + + if (!allowList.contains("\"package_name\": \"$packageName\"")) { + return Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed + } + + return try { + if (getOrigin(allowList) != null) { + Fido2ValidateOriginResult.Success + } else { + Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp + } + } catch (e: IllegalStateException) { + // We know the package name is in the allow list so we can infer that this exception is + // thrown because no matching signature is found. + Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound + } catch (e: IllegalArgumentException) { + // The allow list is not formatted correctly so we notify the user passkeys are not + // supported for this application + Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManager.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManager.kt new file mode 100644 index 000000000..a9c767d0b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManager.kt @@ -0,0 +1,14 @@ +package com.x8bit.bitwarden.ui.autofill.fido2.manager + +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult + +/** + * A manager for completing the FIDO 2 creation process. + */ +interface Fido2CompletionManager { + + /** + * Completes the FIDO 2 creation process with the provided [result]. + */ + fun completeFido2Create(result: Fido2CreateCredentialResult) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManagerImpl.kt new file mode 100644 index 000000000..7156f622e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/autofill/fido2/manager/Fido2CompletionManagerImpl.kt @@ -0,0 +1,47 @@ +package com.x8bit.bitwarden.ui.autofill.fido2.manager + +import android.app.Activity +import android.content.Intent +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.provider.PendingIntentHandler +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult +import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage + +/** + * Primary implementation of [Fido2CompletionManager]. + */ +@OmitFromCoverage +class Fido2CompletionManagerImpl( + private val activity: Activity, +) : Fido2CompletionManager { + + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + override fun completeFido2Create(result: Fido2CreateCredentialResult) { + activity.also { + val intent = Intent() + when (result) { + is Fido2CreateCredentialResult.Error -> { + PendingIntentHandler + .setCreateCredentialException( + intent = intent, + exception = result.exception, + ) + } + + is Fido2CreateCredentialResult.Success -> { + PendingIntentHandler + .setCreateCredentialResponse( + intent = intent, + response = CreatePublicKeyCredentialResponse( + registrationResponseJson = result.registrationResponse, + ), + ) + } + } + it.setResult(Activity.RESULT_OK, intent) + it.finish() + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt index e5cbb4356..e8a9dd936 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/base/util/StringExtensions.kt @@ -25,6 +25,11 @@ import kotlin.math.floor */ const val ZERO_WIDTH_CHARACTER: String = "\u200B" +/** + * URI scheme for a native Android application. + */ +private const val ANDROID_APP_URI_SCHEME: String = "androidapp://" + /** * Returns the original [String] only if: * @@ -68,6 +73,13 @@ fun String.toHostOrPathOrNull(): String? { return uri.host ?: uri.path } +/** + * Returns the original [String] prefixed with `androidapp://` if it doesn't already contain. + */ +fun String.toAndroidAppUriString(): String { + return if (this.startsWith(ANDROID_APP_URI_SCHEME)) this else "$ANDROID_APP_URI_SCHEME$this" +} + /** * Returns the original [String] only if: * diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt index 08f9ec5f7..40ad2f269 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt @@ -9,6 +9,8 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.platform.LocalContext +import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager +import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManagerImpl import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManagerImpl @@ -33,6 +35,7 @@ fun LocalManagerProvider(content: @Composable () -> Unit) { LocalExitManager provides ExitManagerImpl(activity), LocalBiometricsManager provides BiometricsManagerImpl(activity), LocalNfcManager provides NfcManagerImpl(activity), + LocalFido2CompletionManager provides Fido2CompletionManagerImpl(activity), ) { content() } @@ -72,3 +75,8 @@ val LocalPermissionsManager: ProvidableCompositionLocal = co val LocalNfcManager: ProvidableCompositionLocal = compositionLocalOf { error("CompositionLocal NfcManager not present") } + +val LocalFido2CompletionManager: ProvidableCompositionLocal = + compositionLocalOf { + error("CompositionLocal Fido2CompletionManager not present") + } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 9bc12e16f..23d4c11d4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -46,6 +46,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.navigateToVaultAddEdit import com.x8bit.bitwarden.ui.vault.feature.itemlisting.navigateToVaultItemListingAsRoot import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType +import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import java.util.concurrent.atomic.AtomicReference @@ -105,6 +106,7 @@ fun RootNavScreen( is RootNavState.VaultUnlockedForAutofillSelection, is RootNavState.VaultUnlockedForNewSend, is RootNavState.VaultUnlockedForAuthRequest, + is RootNavState.VaultUnlockedForFido2Save, -> VAULT_UNLOCKED_GRAPH_ROUTE } val currentRoute = navController.currentDestination?.rootLevelRoute() @@ -182,6 +184,14 @@ fun RootNavScreen( navOptions = rootNavOptions, ) } + + is RootNavState.VaultUnlockedForFido2Save -> { + navController.navigateToVaultUnlockedGraph(rootNavOptions) + navController.navigateToVaultItemListingAsRoot( + vaultItemListingType = VaultItemListingType.Login, + navOptions = rootNavOptions, + ) + } } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index 6e01d64e9..8fccd4220 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -4,6 +4,7 @@ import android.os.Parcelable import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager @@ -93,6 +94,13 @@ class RootNavViewModel @Inject constructor( RootNavState.VaultUnlockedForAuthRequest } + is SpecialCircumstance.Fido2Save -> { + RootNavState.VaultUnlockedForFido2Save( + activeUserId = userState.activeUserId, + fido2CredentialRequest = specialCircumstance.fido2CredentialRequest, + ) + } + SpecialCircumstance.GeneratorShortcut, SpecialCircumstance.VaultShortcut, null, @@ -172,6 +180,20 @@ sealed class RootNavState : Parcelable { val type: AutofillSelectionData.Type, ) : RootNavState() + /** + * App should show an add item screen for a user to complete the saving of data collected by + * the fido2 credential manager framework + * + * @param activeUserId ID of the active user. Indirectly used to notify [RootNavViewModel] the + * active user has changed. + * @param fido2CredentialRequest System request containing FIDO credential data. + */ + @Parcelize + data class VaultUnlockedForFido2Save( + val activeUserId: String, + val fido2CredentialRequest: Fido2CredentialRequest, + ) : RootNavState() + /** * App should show the new send screen for an unlocked user. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt index 20b9a85fe..28a2623c8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/manager/intent/IntentManagerImpl.kt @@ -49,7 +49,7 @@ private const val TEMP_CAMERA_IMAGE_DIR: String = "camera_temp" * * @see IntentManager.createFido2CreationPendingIntent */ -private const val EXTRA_KEY_USER_ID: String = "EXTRA_KEY_USER_ID" +const val EXTRA_KEY_USER_ID: String = "user_id" /** * The default implementation of the [IntentManager] for simplifying the handling of Android diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt index 5170a934c..f8886f44f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreen.kt @@ -27,6 +27,7 @@ import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountActionItem import com.x8bit.bitwarden.ui.platform.components.account.BitwardenAccountSwitcher @@ -43,6 +44,7 @@ import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.dialog.LoadingDialogState import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter +import com.x8bit.bitwarden.ui.platform.composition.LocalFido2CompletionManager import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager import com.x8bit.bitwarden.ui.platform.feature.search.model.SearchType import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager @@ -69,6 +71,7 @@ fun VaultItemListingScreen( onNavigateToEditSendItem: (sendId: String) -> Unit, onNavigateToSearch: (searchType: SearchType) -> Unit, intentManager: IntentManager = LocalIntentManager.current, + fido2CompletionManager: Fido2CompletionManager = LocalFido2CompletionManager.current, viewModel: VaultItemListingViewModel = hiltViewModel(), ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -130,6 +133,10 @@ fun VaultItemListingScreen( is VaultItemListingEvent.NavigateToCollectionItem -> { onNavigateToVaultItemListing(VaultItemListingType.Collection(event.collectionId)) } + + is VaultItemListingEvent.CompleteFido2Create -> { + fido2CompletionManager.completeFido2Create(event.result) + } } } @@ -138,6 +145,13 @@ fun VaultItemListingScreen( onDismissRequest = remember(viewModel) { { viewModel.trySendAction(VaultItemListingsAction.DismissDialogClick) } }, + onDismissFido2ErrorDialog = remember(viewModel) { + { + viewModel.trySendAction( + VaultItemListingsAction.DismissFido2CreationErrorDialogClick, + ) + } + }, ) VaultItemListingScaffold( @@ -153,6 +167,7 @@ fun VaultItemListingScreen( private fun VaultItemListingDialogs( dialogState: VaultItemListingState.DialogState?, onDismissRequest: () -> Unit, + onDismissFido2ErrorDialog: () -> Unit, ) { when (dialogState) { is VaultItemListingState.DialogState.Error -> BitwardenBasicDialog( @@ -167,6 +182,14 @@ private fun VaultItemListingDialogs( visibilityState = LoadingDialogState.Shown(dialogState.message), ) + is VaultItemListingState.DialogState.Fido2CreationFail -> BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = dialogState.title, + message = dialogState.message, + ), + onDismissRequest = onDismissFido2ErrorDialog, + ) + null -> Unit } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 8209cd111..438439d6a 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -1,11 +1,16 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting import android.os.Parcelable +import androidx.credentials.exceptions.CreateCredentialUnknownException import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.manager.PolicyManager @@ -19,6 +24,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl import com.x8bit.bitwarden.data.platform.repository.util.map +import com.x8bit.bitwarden.data.platform.util.getFido2RpOrNull import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult @@ -29,6 +35,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.concat +import com.x8bit.bitwarden.ui.platform.base.util.toAndroidAppUriString import com.x8bit.bitwarden.ui.platform.base.util.toHostOrPathOrNull import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.platform.components.model.IconData @@ -61,7 +68,7 @@ import javax.inject.Inject * and launches [VaultItemListingEvent] for the [VaultItemListingScreen]. */ @HiltViewModel -@Suppress("MagicNumber", "TooManyFunctions", "LongParameterList") +@Suppress("MagicNumber", "TooManyFunctions", "LongParameterList", "LargeClass") class VaultItemListingViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val clock: Clock, @@ -74,13 +81,20 @@ class VaultItemListingViewModel @Inject constructor( private val cipherMatchingManager: CipherMatchingManager, private val specialCircumstanceManager: SpecialCircumstanceManager, private val policyManager: PolicyManager, + private val fido2CredentialManager: Fido2CredentialManager, ) : BaseViewModel( initialState = run { val userState = requireNotNull(authRepository.userStateFlow.value) val activeAccountSummary = userState.toActiveAccountSummary() val accountSummaries = userState.toAccountSummaries() - val specialCircumstance = - specialCircumstanceManager.specialCircumstance as? SpecialCircumstance.AutofillSelection + val specialCircumstance = specialCircumstanceManager.specialCircumstance + val autofillSelectionData = specialCircumstance as? SpecialCircumstance.AutofillSelection + val fido2CreationData = specialCircumstance as? SpecialCircumstance.Fido2Save + val shouldFinishOnComplete = autofillSelectionData + ?.shouldFinishWhenComplete + ?: (fido2CreationData != null) + val dialogState = fido2CreationData + ?.let { VaultItemListingState.DialogState.Loading(R.string.loading.asText()) } VaultItemListingState( itemListingType = VaultItemListingArgs(savedStateHandle = savedStateHandle) .vaultItemListingType @@ -93,13 +107,14 @@ class VaultItemListingViewModel @Inject constructor( baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl, isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled, isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value, - dialogState = null, + dialogState = dialogState, policyDisablesSend = policyManager .getActivePolicies(type = PolicyTypeJson.DISABLE_SEND) .any(), - autofillSelectionData = specialCircumstance?.autofillSelectionData, - shouldFinishOnComplete = specialCircumstance?.shouldFinishWhenComplete ?: false, + autofillSelectionData = autofillSelectionData?.autofillSelectionData, + shouldFinishOnComplete = shouldFinishOnComplete, hasMasterPassword = userState.activeAccount.hasMasterPassword, + fido2CredentialRequest = fido2CreationData?.fido2CredentialRequest, ) }, ) { @@ -116,16 +131,18 @@ class VaultItemListingViewModel @Inject constructor( .onEach { sendAction(VaultItemListingsAction.Internal.IconLoadingSettingReceive(it)) } .launchIn(viewModelScope) - vaultRepository - .vaultDataStateFlow - .onEach { - sendAction( - VaultItemListingsAction.Internal.VaultDataReceive( - it.filterForAutofillIfNecessary(), - ), - ) - } - .launchIn(viewModelScope) + viewModelScope.launch { + state + .fido2CredentialRequest + ?.let { request -> + sendAction( + VaultItemListingsAction.Internal.ValidateFido2OriginResultReceive( + result = fido2CredentialManager.validateOrigin(request), + ), + ) + } + ?: observeVaultData() + } policyManager .getActivePoliciesFlow(type = PolicyTypeJson.DISABLE_SEND) @@ -134,12 +151,30 @@ class VaultItemListingViewModel @Inject constructor( .launchIn(viewModelScope) } + private fun observeVaultData() { + vaultRepository + .vaultDataStateFlow + .map { + VaultItemListingsAction.Internal.VaultDataReceive( + it + .filterForAutofillIfNecessary() + .filterForFido2CreationIfNecessary(), + ) + } + .onEach(::sendAction) + .launchIn(viewModelScope) + } + override fun handleAction(action: VaultItemListingsAction) { when (action) { is VaultItemListingsAction.LockAccountClick -> handleLockAccountClick(action) is VaultItemListingsAction.LogoutAccountClick -> handleLogoutAccountClick(action) is VaultItemListingsAction.SwitchAccountClick -> handleSwitchAccountClick(action) is VaultItemListingsAction.DismissDialogClick -> handleDismissDialogClick() + is VaultItemListingsAction.DismissFido2CreationErrorDialogClick -> { + handleDismissFido2ErrorDialogClick() + } + is VaultItemListingsAction.BackClick -> handleBackClick() is VaultItemListingsAction.FolderClick -> handleFolderClick(action) is VaultItemListingsAction.CollectionClick -> handleCollectionClick(action) @@ -249,6 +284,7 @@ class VaultItemListingViewModel @Inject constructor( sendEvent(VaultItemListingEvent.NavigateToSendItem(id = action.sendId)) } + @Suppress("ReturnCount") private fun handleItemClick(action: VaultItemListingsAction.ItemClick) { if (state.isAutofill) { val cipherView = getCipherViewOrNull(action.id) ?: return @@ -256,6 +292,16 @@ class VaultItemListingViewModel @Inject constructor( return } + if (state.isFido2Creation) { + val cipherView = getCipherViewOrNull(action.id) ?: return + val credentialRequest = state.fido2CredentialRequest ?: return + fido2CredentialManager.createCredentialForCipher( + credentialRequest = credentialRequest, + cipherView = cipherView, + ) + return + } + val event = when (state.itemListingType) { is VaultItemListingState.ItemListingType.Vault -> { VaultItemListingEvent.NavigateToVaultItem(id = action.id) @@ -337,6 +383,16 @@ class VaultItemListingViewModel @Inject constructor( mutableStateFlow.update { it.copy(dialogState = null) } } + private fun handleDismissFido2ErrorDialogClick() { + sendEvent( + VaultItemListingEvent.CompleteFido2Create( + result = Fido2CreateCredentialResult.Error( + exception = CreateCredentialUnknownException(), + ), + ), + ) + } + private fun handleBackClick() { sendEvent( event = VaultItemListingEvent.NavigateBack, @@ -456,6 +512,10 @@ class VaultItemListingViewModel @Inject constructor( is VaultItemListingsAction.Internal.PolicyUpdateReceive -> { handlePolicyUpdateReceive(action) } + + is VaultItemListingsAction.Internal.ValidateFido2OriginResultReceive -> { + handleValidateFido2OriginResultReceive(action) + } } } @@ -671,6 +731,64 @@ class VaultItemListingViewModel @Inject constructor( } } + private fun handleValidateFido2OriginResultReceive( + action: VaultItemListingsAction.Internal.ValidateFido2OriginResultReceive, + ) { + when (val result = action.result) { + is Fido2ValidateOriginResult.Error -> { + handleFido2OriginValidationFail(result) + } + + Fido2ValidateOriginResult.Success -> { + handleFido2OriginValidationSuccess() + } + } + } + + private fun handleFido2OriginValidationFail(error: Fido2ValidateOriginResult.Error) { + val messageResId = when (error) { + Fido2ValidateOriginResult.Error.ApplicationNotFound -> { + R.string.passkey_operation_failed_because_app_not_found_in_asset_links + } + + Fido2ValidateOriginResult.Error.ApplicationNotVerified -> { + R.string.passkey_operation_failed_because_app_could_not_be_verified + } + + Fido2ValidateOriginResult.Error.AssetLinkNotFound -> { + R.string.passkey_operation_failed_because_of_missing_asset_links + } + + Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed -> { + R.string.passkey_operation_failed_because_browser_is_not_privileged + } + + Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp -> { + R.string.passkeys_not_supported_for_this_app + } + + Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound -> { + R.string.passkey_operation_failed_because_browser_signature_does_not_match + } + + Fido2ValidateOriginResult.Error.Unknown -> { + R.string.generic_error_message + } + } + mutableStateFlow.update { + it.copy( + dialogState = VaultItemListingState.DialogState.Fido2CreationFail( + title = R.string.an_error_has_occurred.asText(), + message = messageResId.asText(), + ), + ) + } + } + + private fun handleFido2OriginValidationSuccess() { + observeVaultData() + } + private fun updateStateWithVaultData(vaultData: VaultData, clearDialogState: Boolean) { mutableStateFlow.update { currentState -> currentState.copy( @@ -691,6 +809,7 @@ class VaultItemListingViewModel @Inject constructor( baseIconUrl = state.baseIconUrl, isIconLoadingDisabled = state.isIconLoadingDisabled, autofillSelectionData = state.autofillSelectionData, + fido2CreationData = state.fido2CredentialRequest, ) } @@ -736,6 +855,26 @@ class VaultItemListingViewModel @Inject constructor( ) } } + + /** + * Takes the given vault data and filters it for fido2 credential creation if necessary. + */ + @Suppress("MaxLineLength") + private suspend fun DataState.filterForFido2CreationIfNecessary(): DataState { + val request = state.fido2CredentialRequest ?: return this + return this.map { vaultData -> + val matchUri = request.origin + ?: request.packageName + .toAndroidAppUriString() + + vaultData.copy( + cipherViewList = cipherMatchingManager.filterCiphersForMatches( + ciphers = vaultData.cipherViewList, + matchUri = matchUri, + ), + ) + } + } } /** @@ -755,6 +894,7 @@ data class VaultItemListingState( // Internal private val isPullToRefreshSettingEnabled: Boolean, val autofillSelectionData: AutofillSelectionData? = null, + val fido2CredentialRequest: Fido2CredentialRequest? = null, val shouldFinishOnComplete: Boolean = false, val hasMasterPassword: Boolean, ) { @@ -764,6 +904,12 @@ data class VaultItemListingState( val isAutofill: Boolean get() = autofillSelectionData != null + /** + * Whether or not this represents a listing screen for FIDO2 creation. + */ + val isFido2Creation: Boolean + get() = fido2CredentialRequest != null + /** * A displayable title for the AppBar. */ @@ -772,6 +918,10 @@ data class VaultItemListingState( ?.uri ?.toHostOrPathOrNull() ?.let { R.string.items_for_uri.asText(it) } + ?: fido2CredentialRequest + ?.callingAppInfo + ?.getFido2RpOrNull() + ?.let { R.string.items_for_uri.asText(it) } ?: itemListingType.titleText /** @@ -783,17 +933,17 @@ data class VaultItemListingState( /** * Whether or not the account switcher should be shown. */ - val shouldShowAccountSwitcher: Boolean get() = isAutofill + val shouldShowAccountSwitcher: Boolean get() = isAutofill || isFido2Creation /** * Whether or not the navigation icon should be shown. */ - val shouldShowNavigationIcon: Boolean get() = !isAutofill + val shouldShowNavigationIcon: Boolean get() = !isAutofill && !isFido2Creation /** * Whether or not the overflow menu should be shown. */ - val shouldShowOverflowMenu: Boolean get() = !isAutofill + val shouldShowOverflowMenu: Boolean get() = !isAutofill && !isFido2Creation /** * Represents the current state of any dialogs on the screen. @@ -809,6 +959,16 @@ data class VaultItemListingState( val message: Text, ) : DialogState() + /** + * Represents a dialog indicating that the FIDO 2 credential creation flow was not + * successful. + */ + @Parcelize + data class Fido2CreationFail( + val title: Text, + val message: Text, + ) : DialogState() + /** * Represents a loading dialog with the given [message]. */ @@ -888,6 +1048,7 @@ data class VaultItemListingState( * @property overflowOptions list of options for the item's overflow menu. * @property optionsTestTag The test tag associated with the [overflowOptions]. * @property isAutofill whether or not this screen is part of an autofill flow. + * @property isFido2Creation whether or not this screen is part of fido2 creation flow. * @property shouldShowMasterPasswordReprompt whether or not a master password reprompt is * required for various secure actions. */ @@ -903,6 +1064,7 @@ data class VaultItemListingState( val overflowOptions: List, val optionsTestTag: String, val isAutofill: Boolean, + val isFido2Creation: Boolean, val shouldShowMasterPasswordReprompt: Boolean, ) @@ -1131,6 +1293,15 @@ sealed class VaultItemListingEvent { * @property text the text to display. */ data class ShowToast(val text: Text) : VaultItemListingEvent() + + /** + * Complete the current FIDO 2 credential creation process. + * + * @property result the result of FIDO 2 credential creation. + */ + data class CompleteFido2Create( + val result: Fido2CreateCredentialResult, + ) : VaultItemListingEvent() } /** @@ -1165,6 +1336,11 @@ sealed class VaultItemListingsAction { */ data object DismissDialogClick : VaultItemListingsAction() + /** + * Click to dismiss the FIDO 2 creation error dialog. + */ + data object DismissFido2CreationErrorDialogClick : VaultItemListingsAction() + /** * Click the refresh button. */ @@ -1293,6 +1469,14 @@ sealed class VaultItemListingsAction { data class PolicyUpdateReceive( val policyDisablesSend: Boolean, ) : Internal() + + /** + * Indicates that a result for validating the relying party's origin during a FIDO 2 + * request. + */ + data class ValidateFido2OriginResultReceive( + val result: Fido2ValidateOriginResult, + ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt index 5d7459ed3..3aeabcf8e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -11,6 +11,7 @@ import com.bitwarden.core.FolderView import com.bitwarden.core.SendType import com.bitwarden.core.SendView import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.util.subtitle import com.x8bit.bitwarden.data.vault.repository.model.VaultData @@ -99,6 +100,7 @@ fun VaultData.toViewState( baseIconUrl: String, isIconLoadingDisabled: Boolean, autofillSelectionData: AutofillSelectionData?, + fido2CreationData: Fido2CredentialRequest?, ): VaultItemListingState.ViewState { val filteredCipherViewList = cipherViewList .filter { cipherView -> @@ -126,6 +128,7 @@ fun VaultData.toViewState( hasMasterPassword = hasMasterPassword, isIconLoadingDisabled = isIconLoadingDisabled, isAutofill = autofillSelectionData != null, + isFido2Creation = fido2CreationData != null, ), displayFolderList = folderList.map { folderView -> VaultItemListingState.FolderDisplayItem( @@ -251,6 +254,7 @@ private fun List.toDisplayItemList( hasMasterPassword: Boolean, isIconLoadingDisabled: Boolean, isAutofill: Boolean, + isFido2Creation: Boolean, ): List = this.map { it.toDisplayItem( @@ -258,6 +262,7 @@ private fun List.toDisplayItemList( hasMasterPassword = hasMasterPassword, isIconLoadingDisabled = isIconLoadingDisabled, isAutofill = isAutofill, + isFido2Creation = isFido2Creation, ) } @@ -277,6 +282,7 @@ private fun CipherView.toDisplayItem( hasMasterPassword: Boolean, isIconLoadingDisabled: Boolean, isAutofill: Boolean, + isFido2Creation: Boolean, ): VaultItemListingState.DisplayItem = VaultItemListingState.DisplayItem( id = id.orEmpty(), @@ -293,6 +299,7 @@ private fun CipherView.toDisplayItem( overflowOptions = toOverflowActions(hasMasterPassword = hasMasterPassword), optionsTestTag = "CipherOptionsButton", isAutofill = isAutofill, + isFido2Creation = isFido2Creation, shouldShowMasterPasswordReprompt = reprompt == CipherRepromptType.PASSWORD, ) @@ -344,6 +351,7 @@ private fun SendView.toDisplayItem( optionsTestTag = "SendOptionsButton", isAutofill = false, shouldShowMasterPasswordReprompt = false, + isFido2Creation = false, ) @get:DrawableRes diff --git a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt index 8e3d05184..f202eed06 100644 --- a/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/MainViewModelTest.kt @@ -1,12 +1,18 @@ package com.x8bit.bitwarden import android.content.Intent +import android.content.pm.SigningInfo import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.bitwarden.core.CipherView import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull +import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult +import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CredentialRequestOrNull import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem @@ -18,6 +24,7 @@ import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManage import com.x8bit.bitwarden.data.platform.manager.model.PasswordlessRequestData import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent import com.x8bit.bitwarden.data.vault.repository.VaultRepository @@ -26,6 +33,7 @@ import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppThem import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.platform.util.isMyVaultShortcut import com.x8bit.bitwarden.ui.platform.util.isPasswordGeneratorShortcut +import io.mockk.coEvery import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -45,16 +53,19 @@ class MainViewModelTest : BaseViewModelTest() { private val autofillSelectionManager: AutofillSelectionManager = AutofillSelectionManagerImpl() private val mutableUserStateFlow = MutableStateFlow(null) - private val authRepository = mockk { - every { userStateFlow } returns mutableUserStateFlow - } private val mutableAppThemeFlow = MutableStateFlow(AppTheme.DEFAULT) private val mutableScreenCaptureAllowedFlow = MutableStateFlow(true) + private val fido2CredentialManager = mockk() private val settingsRepository = mockk { every { appTheme } returns AppTheme.DEFAULT every { appThemeStateFlow } returns mutableAppThemeFlow every { isScreenCaptureAllowedStateFlow } returns mutableScreenCaptureAllowedFlow } + private val authRepository = mockk { + every { activeUserId } returns DEFAULT_USER_STATE.activeUserId + every { userStateFlow } returns mutableUserStateFlow + every { switchAccount(any()) } returns SwitchAccountResult.NoChange + } private val mutableVaultStateEventFlow = bufferedMutableSharedFlow() private val vaultRepository = mockk { every { vaultStateEventFlow } returns mutableVaultStateEventFlow @@ -74,6 +85,7 @@ class MainViewModelTest : BaseViewModelTest() { Intent::getPasswordlessRequestDataIntentOrNull, Intent::getAutofillSaveItemOrNull, Intent::getAutofillSelectionDataOrNull, + Intent::getFido2CredentialRequestOrNull, ) mockkStatic( Intent::isMyVaultShortcut, @@ -349,6 +361,111 @@ class MainViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `on ReceiveFirstIntent with fido2 request data should set the special circumstance to Fido2Save`() { + val viewModel = createViewModel() + val fido2CredentialRequest = Fido2CredentialRequest( + userId = DEFAULT_USER_STATE.activeUserId, + requestJson = """{"mockRequestJson":1}""", + packageName = "com.x8bit.bitwarden", + signingInfo = SigningInfo(), + origin = "mockOrigin", + ) + val mockIntent = mockk { + every { getFido2CredentialRequestOrNull() } returns fido2CredentialRequest + every { getPasswordlessRequestDataIntentOrNull() } returns null + every { getAutofillSelectionDataOrNull() } returns null + every { getAutofillSaveItemOrNull() } returns null + every { isMyVaultShortcut } returns false + every { isPasswordGeneratorShortcut } returns false + } + every { intentManager.getShareDataFromIntent(mockIntent) } returns null + coEvery { + fido2CredentialManager.validateOrigin(any()) + } returns Fido2ValidateOriginResult.Success + + viewModel.trySendAction( + MainAction.ReceiveFirstIntent( + intent = mockIntent, + ), + ) + + assertEquals( + SpecialCircumstance.Fido2Save( + fido2CredentialRequest = fido2CredentialRequest, + ), + specialCircumstanceManager.specialCircumstance, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `on ReceiveFirstIntent with fido2 request data should switch users if active user is not selected`() { + mutableUserStateFlow.value = DEFAULT_USER_STATE + val viewModel = createViewModel() + val fido2CredentialRequest = Fido2CredentialRequest( + userId = "selectedUserId", + requestJson = """{"mockRequestJson":1}""", + packageName = "com.x8bit.bitwarden", + signingInfo = SigningInfo(), + origin = "mockOrigin", + ) + val mockIntent = mockk { + every { getFido2CredentialRequestOrNull() } returns fido2CredentialRequest + every { getPasswordlessRequestDataIntentOrNull() } returns null + every { getAutofillSelectionDataOrNull() } returns null + every { getAutofillSaveItemOrNull() } returns null + every { isMyVaultShortcut } returns false + every { isPasswordGeneratorShortcut } returns false + } + every { intentManager.getShareDataFromIntent(mockIntent) } returns null + coEvery { + fido2CredentialManager.validateOrigin(any()) + } returns Fido2ValidateOriginResult.Success + + viewModel.trySendAction( + MainAction.ReceiveFirstIntent( + intent = mockIntent, + ), + ) + + verify(exactly = 1) { authRepository.switchAccount(fido2CredentialRequest.userId) } + } + + @Suppress("MaxLineLength") + @Test + fun `on ReceiveFirstIntent with fido2 request data should not switch users if active user is selected`() { + val viewModel = createViewModel() + val fido2CredentialRequest = Fido2CredentialRequest( + userId = DEFAULT_USER_STATE.activeUserId, + requestJson = """{"mockRequestJson":1}""", + packageName = "com.x8bit.bitwarden", + signingInfo = SigningInfo(), + origin = "mockOrigin", + ) + val mockIntent = mockk { + every { getFido2CredentialRequestOrNull() } returns fido2CredentialRequest + every { getPasswordlessRequestDataIntentOrNull() } returns null + every { getAutofillSelectionDataOrNull() } returns null + every { getAutofillSaveItemOrNull() } returns null + every { isMyVaultShortcut } returns false + every { isPasswordGeneratorShortcut } returns false + } + every { intentManager.getShareDataFromIntent(mockIntent) } returns null + coEvery { + fido2CredentialManager.validateOrigin(any()) + } returns Fido2ValidateOriginResult.Success + + viewModel.trySendAction( + MainAction.ReceiveFirstIntent( + intent = mockIntent, + ), + ) + + verify(exactly = 0) { authRepository.switchAccount(fido2CredentialRequest.userId) } + } + @Suppress("MaxLineLength") @Test fun `on ReceiveNewIntent with share data should set the special circumstance to ShareNewSend`() { @@ -532,10 +649,10 @@ class MainViewModelTest : BaseViewModelTest() { autofillSelectionManager = autofillSelectionManager, specialCircumstanceManager = specialCircumstanceManager, garbageCollectionManager = garbageCollectionManager, - authRepository = authRepository, + intentManager = intentManager, settingsRepository = settingsRepository, vaultRepository = vaultRepository, - intentManager = intentManager, + authRepository = authRepository, savedStateHandle = savedStateHandle.apply { set(SPECIAL_CIRCUMSTANCE_KEY, initialSpecialCircumstance) }, @@ -543,3 +660,23 @@ class MainViewModelTest : BaseViewModelTest() { } private const val SPECIAL_CIRCUMSTANCE_KEY: String = "special-circumstance" +private val DEFAULT_ACCOUNT = UserState.Account( + userId = "activeUserId", + name = "Active User", + email = "active@bitwarden.com", + environment = Environment.Us, + avatarColorHex = "#aa00aa", + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = emptyList(), + needsMasterPassword = false, + trustedDevice = null, +) + +private val DEFAULT_USER_STATE = UserState( + activeUserId = "activeUserId", + accounts = listOf(DEFAULT_ACCOUNT), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/service/DigitalAssetLinkServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/service/DigitalAssetLinkServiceTest.kt new file mode 100644 index 000000000..9c2a06d80 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/datasource/network/service/DigitalAssetLinkServiceTest.kt @@ -0,0 +1,67 @@ +package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service + +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.api.DigitalAssetLinkApi +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson +import com.x8bit.bitwarden.data.platform.base.BaseServiceTest +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import retrofit2.create + +class DigitalAssetLinkServiceTest : BaseServiceTest() { + + private val digitalAssetLinkApi: DigitalAssetLinkApi = retrofit.create() + + private val digitalAssetLinkService: DigitalAssetLinkService = DigitalAssetLinkServiceImpl( + digitalAssetLinkApi = digitalAssetLinkApi, + ) + + @Test + fun `getDigitalAssetLinkForRp should return the correct response`() = runTest { + server.enqueue(MockResponse().setBody(GET_DIGITAL_ASSET_LINK_SUCCESS_JSON)) + val result = digitalAssetLinkService.getDigitalAssetLinkForRp( + scheme = url.scheme, + relyingParty = url.host, + ) + assertEquals( + createDigitalAssetLinkResponse(), + result.getOrThrow(), + ) + } +} + +@Suppress("MaxLineLength") +private fun createDigitalAssetLinkResponse() = listOf( + DigitalAssetLinkResponseJson( + relation = listOf( + "delegate_permission/common.get_login_creds", + "delegate_permission/common.handle_all_urls", + ), + target = DigitalAssetLinkResponseJson.Target( + namespace = "android_app", + packageName = "com.mock.package", + sha256CertFingerprints = listOf( + "00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F", + ), + ), + ), +) + +private const val GET_DIGITAL_ASSET_LINK_SUCCESS_JSON = """ +[ + { + "relation": [ + "delegate_permission/common.get_login_creds", + "delegate_permission/common.handle_all_urls" + ], + "target": { + "namespace": "android_app", + "package_name": "com.mock.package", + "sha256_cert_fingerprints": [ + "00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F" + ] + } + } +] +""" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerTest.kt new file mode 100644 index 000000000..b48583a46 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/autofill/fido2/manager/Fido2CredentialManagerTest.kt @@ -0,0 +1,331 @@ +package com.x8bit.bitwarden.data.autofill.fido2.manager + +import android.content.pm.Signature +import android.content.pm.SigningInfo +import androidx.credentials.provider.CallingAppInfo +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.DigitalAssetLinkResponseJson +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.PublicKeyCredentialCreationOptions +import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.service.DigitalAssetLinkService +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult +import com.x8bit.bitwarden.data.platform.manager.AssetManager +import com.x8bit.bitwarden.data.platform.util.asFailure +import com.x8bit.bitwarden.data.platform.util.asSuccess +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.security.MessageDigest + +class Fido2CredentialManagerTest { + + private lateinit var fido2CredentialManager: Fido2CredentialManager + + private val assetManager: AssetManager = mockk { + coEvery { readAsset(any()) } returns DEFAULT_ALLOW_LIST.asSuccess() + } + private val digitalAssetLinkService = mockk { + coEvery { + getDigitalAssetLinkForRp(relyingParty = any()) + } returns DEFAULT_STATEMENT_LIST.asSuccess() + } + private val mockCreateOptions = mockk { + every { + relyingParty + } returns PublicKeyCredentialCreationOptions.PublicKeyCredentialRpEntity( + name = "mockRpName", + id = "www.bitwarden.com", + ) + } + private val json = mockk { + every { + decodeFromString(any()) + } returns mockCreateOptions + } + private val mockPrivilegedCallingAppInfo = mockk { + every { packageName } returns "com.x8bit.bitwarden" + every { isOriginPopulated() } returns true + every { getOrigin(any()) } returns "com.x8bit.bitwarden" + } + private val mockPrivilegedAppRequest = mockk { + every { callingAppInfo } returns mockPrivilegedCallingAppInfo + } + private val mockSigningInfo = mockk { + every { apkContentsSigners } returns arrayOf(Signature("0987654321ABCDEF")) + } + private val mockUnprivilegedCallingAppInfo = CallingAppInfo( + packageName = "com.x8bit.bitwarden", + signingInfo = mockSigningInfo, + origin = null, + ) + private val mockUnprivilegedAppRequest = mockk { + every { callingAppInfo } returns mockUnprivilegedCallingAppInfo + every { requestJson } returns "{}" + } + private val mockMessageDigest = mockk { + every { digest(any()) } returns "0987654321ABCDEF".toByteArray() + } + + @BeforeEach + fun setUp() { + mockkStatic(MessageDigest::class) + every { MessageDigest.getInstance(any()) } returns mockMessageDigest + + fido2CredentialManager = Fido2CredentialManagerImpl( + assetManager = assetManager, + digitalAssetLinkService = digitalAssetLinkService, + json = json, + ) + } + + @AfterEach + fun tearDown() { + unmockkStatic(MessageDigest::class) + } + + @Test + fun `validateOrigin should load allow list when origin is populated`() = + runTest { + fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest) + + coVerify(exactly = 1) { + assetManager.readAsset( + fileName = "fido2_privileged_allow_list.json", + ) + } + } + + @Test + fun `validateOrigin should return Success when privileged app is allowed`() = + runTest { + assertEquals( + Fido2ValidateOriginResult.Success, + fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return PrivilegedAppSignatureNotFound when privileged app signature is not found in allow list`() = + runTest { + every { mockPrivilegedCallingAppInfo.getOrigin(any()) } throws IllegalStateException() + + assertEquals( + Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound, + fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return PrivilegedAppNotAllowed when privileged app package name is not found in allow list`() = + runTest { + coEvery { assetManager.readAsset(any()) } returns MISSING_PACKAGE_ALLOW_LIST.asSuccess() + + assertEquals( + Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed, + fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest), + ) + } + + @Test + fun `validateOrigin should return error when allow list is unreadable`() = runTest { + coEvery { assetManager.readAsset(any()) } returns IllegalStateException().asFailure() + + assertEquals( + Fido2ValidateOriginResult.Error.Unknown, + fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest), + ) + } + + @Test + fun `validateOrigin should return PasskeyNotSupportedForApp when allow list is invalid`() = + runTest { + every { + mockPrivilegedCallingAppInfo.getOrigin(any()) + } throws IllegalArgumentException() + + assertEquals( + Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp, + fido2CredentialManager.validateOrigin(mockPrivilegedAppRequest), + ) + } + + @Test + fun `validateOrigin should return success when asset links contains matching statement`() = + runTest { + assertEquals( + Fido2ValidateOriginResult.Success, + fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest), + ) + } + + @Test + fun `validateOrigin should return error when request cannot be decoded`() = runTest { + every { + json.decodeFromString(any()) + } throws SerializationException() + + assertEquals( + Fido2ValidateOriginResult.Error.AssetLinkNotFound, + fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest), + ) + } + + @Test + fun `validateOrigin should return error when request cannot be cast to object type`() = + runTest { + every { + json.decodeFromString(any()) + } throws IllegalArgumentException() + + assertEquals( + Fido2ValidateOriginResult.Error.AssetLinkNotFound, + fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest), + ) + } + + @Test + fun `validateOrigin should return error when asset links are unavailable`() = runTest { + coEvery { + digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = any()) + } returns Throwable().asFailure() + + assertEquals( + fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest), + Fido2ValidateOriginResult.Error.AssetLinkNotFound, + ) + } + + @Test + fun `validateOrigin should return error when asset links does not contain package name`() = + runTest { + every { mockUnprivilegedAppRequest.callingAppInfo } returns CallingAppInfo( + packageName = "its.a.trap", + signingInfo = mockSigningInfo, + origin = null, + ) + assertEquals( + Fido2ValidateOriginResult.Error.ApplicationNotFound, + fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validateOrigin should return error when asset links does not contain android app namespace`() = + runTest { + coEvery { + digitalAssetLinkService.getDigitalAssetLinkForRp(relyingParty = any()) + } returns listOf( + DEFAULT_STATEMENT.copy( + target = DEFAULT_STATEMENT.target.copy( + namespace = "its_a_trap", + ), + ), + ) + .asSuccess() + + assertEquals( + Fido2ValidateOriginResult.Error.ApplicationNotFound, + fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest), + ) + } + + @Test + fun `validateOrigin should return error when asset links certificate hash no match`() = + runTest { + every { + mockMessageDigest.digest(any()) + } returns "ITSATRAP".toByteArray() + assertEquals( + Fido2ValidateOriginResult.Error.ApplicationNotVerified, + fido2CredentialManager.validateOrigin(mockUnprivilegedAppRequest), + ) + } + + @Test + fun `createCredentialForCipher should return error while not implemented`() { + val result = fido2CredentialManager.createCredentialForCipher( + credentialRequest = mockk(), + cipherView = mockk(), + ) + + assertTrue( + result is Fido2CreateCredentialResult.Error, + ) + } +} + +@Suppress("MaxLineLength") +private const val DEFAULT_CERT_FINGERPRINT = + "30:39:38:37:36:35:34:33:32:31:41:42:43:44:45:46" +private val DEFAULT_STATEMENT = DigitalAssetLinkResponseJson( + relation = listOf( + "delegate_permission/common.get_login_creds", + "delegate_permission/common.handle_all_urls", + ), + target = DigitalAssetLinkResponseJson.Target( + namespace = "android_app", + packageName = "com.x8bit.bitwarden", + sha256CertFingerprints = listOf( + DEFAULT_CERT_FINGERPRINT, + ), + ), +) +private val DEFAULT_STATEMENT_LIST = listOf(DEFAULT_STATEMENT) +private const val DEFAULT_ALLOW_LIST = """ +{ + "apps": [ + { + "type": "android", + "info": { + "package_name": "com.x8bit.bitwarden", + "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" + } + ] + } + } + ] +} +""" +private const val MISSING_PACKAGE_ALLOW_LIST = """ +{ + "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" + } + ] + } + } + ] +} +""" diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/base/BaseServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/base/BaseServiceTest.kt index 659c9f982..57bcf0e95 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/base/BaseServiceTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/base/BaseServiceTest.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.base import com.x8bit.bitwarden.data.platform.datasource.network.core.ResultCallAdapterFactory import com.x8bit.bitwarden.data.platform.datasource.network.di.PlatformNetworkModule +import okhttp3.HttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.AfterEach @@ -17,10 +18,12 @@ abstract class BaseServiceTest { protected val server = MockWebServer().apply { start() } + protected val url: HttpUrl = server.url("/") + protected val urlPrefix: String get() = "http://${server.hostName}:${server.port}" protected val retrofit: Retrofit = Retrofit.Builder() - .baseUrl(server.url("/").toString()) + .baseUrl(url.toString()) .addCallAdapterFactory(ResultCallAdapterFactory()) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt new file mode 100644 index 000000000..5e4c74664 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/util/CallingAppInfoExtensionsTest.kt @@ -0,0 +1,185 @@ +package com.x8bit.bitwarden.data.platform.util + +import android.content.pm.Signature +import android.content.pm.SigningInfo +import android.util.Base64 +import androidx.credentials.provider.CallingAppInfo +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.security.MessageDigest + +class CallingAppInfoExtensionsTest { + + @BeforeEach + fun setUp() { + mockkStatic(MessageDigest::class) + mockkStatic(Base64::class) + } + + @AfterEach + fun tearDown() { + unmockkStatic(MessageDigest::class) + unmockkStatic(Base64::class) + } + + @Test + fun `getFido2RpOrNull should return null when origin is populated with invalid URI`() { + val mockCallingAppInfo = mockk { + every { isOriginPopulated() } returns true + every { origin } returns "invalidUri9685%^$^&(*" + } + + assertNull(mockCallingAppInfo.getFido2RpOrNull()) + } + + @Test + fun `getFido2RpOrNull should return origin when origin is populated`() { + val mockCallingAppInfo = mockk { + every { isOriginPopulated() } returns true + every { origin } returns "mockUri" + } + + assertEquals("mockUri", mockCallingAppInfo.getFido2RpOrNull()) + } + + @Test + fun `getFido2RpOrNull should return null when origin is null`() { + val mockCallingAppInfo = mockk { + every { isOriginPopulated() } returns true + every { origin } returns null + } + + assertNull(mockCallingAppInfo.getFido2RpOrNull()) + } + + @Test + fun `getFido2RpOrNull should return package name when origin is not populated`() { + val mockCallingAppInfo = mockk { + every { isOriginPopulated() } returns false + every { packageName } returns "mockPackageName" + } + + assertEquals("mockPackageName", mockCallingAppInfo.getFido2RpOrNull()) + } + + @Test + fun `getCallingAppApkFingerprint should return key hash`() { + val mockMessageDigest = mockk { + every { digest(any()) } returns DEFAULT_SIGNATURE.toByteArray() + } + every { MessageDigest.getInstance(any()) } returns mockMessageDigest + every { Base64.encodeToString(any(), any()) } returns DEFAULT_SIGNATURE + + val mockSigningInfo = mockk { + every { apkContentsSigners } returns arrayOf(Signature(DEFAULT_SIGNATURE)) + } + val appInfo = mockk { + every { packageName } returns "packageName" + every { signingInfo } returns mockSigningInfo + every { origin } returns null + } + assertEquals( + DEFAULT_SIGNATURE_HASH, + appInfo.getCallingAppApkFingerprint(), + ) + } + + @Test + fun `validatePrivilegedApp should return Success when privileged app is allowed`() { + val mockAppInfo = mockk { + every { getOrigin(any()) } returns "origin" + every { packageName } returns "com.x8bit.bitwarden" + } + + assertEquals( + Fido2ValidateOriginResult.Success, + mockAppInfo.validatePrivilegedApp( + allowList = DEFAULT_ALLOW_LIST, + ), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validatePrivilegedApp should return PasskeyNotSupportedForApp when allow list is invalid`() { + val appInfo = mockk { + every { packageName } returns "com.x8bit.bitwarden" + every { getOrigin(any()) } throws IllegalArgumentException() + } + + assertEquals( + Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp, + appInfo.validatePrivilegedApp( + allowList = INVALID_ALLOW_LIST, + ), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `validatePrivilegedApp should return PrivilegedAppNotAllowed when calling app is not present in allow list`() { + val appInfo = mockk { + every { packageName } returns "packageName" + every { origin } returns "origin" + } + + assertEquals( + Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed, + appInfo.validatePrivilegedApp( + allowList = DEFAULT_ALLOW_LIST, + ), + ) + } +} + +private const val DEFAULT_SIGNATURE = "0987654321ABCDEF" +private const val DEFAULT_SIGNATURE_HASH = "30:39:38:37:36:35:34:33:32:31:41:42:43:44:45:46" +private const val DEFAULT_ALLOW_LIST = """ +{ + "apps": [ + { + "type": "android", + "info": { + "package_name": "com.x8bit.bitwarden", + "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" + } + ] + } + } + ] +} +""" +private const val INVALID_ALLOW_LIST = """ + "apps": [ + { + "type": "android", + "info": { + "package_name": "com.x8bit.bitwarden", + "signatures": [ + { + "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" + }, + { + "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" + } + ] + } + } + ] +} +""" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index de094412c..433cc0c56 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -1,7 +1,9 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav +import android.content.pm.SigningInfo import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl @@ -404,6 +406,50 @@ class RootNavViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `when the active user has an unlocked vault but there is a Fido2Save special circumstance the nav state should be VaultUnlockedForFido2Save`() { + val fido2CredentialRequest = Fido2CredentialRequest( + userId = "activeUserId", + requestJson = "{}", + packageName = "com.x8bit.bitwarden", + signingInfo = SigningInfo(), + origin = "mockOrigin", + ) + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.Fido2Save(fido2CredentialRequest) + mutableUserStateFlow.tryEmit( + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarHexColor", + environment = Environment.Us, + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = emptyList(), + needsMasterPassword = false, + trustedDevice = null, + ), + ), + ), + ) + val viewModel = createViewModel() + assertEquals( + RootNavState.VaultUnlockedForFido2Save( + activeUserId = "activeUserId", + fido2CredentialRequest = fido2CredentialRequest, + ), + viewModel.stateFlow.value, + ) + } + @Test fun `when the active user has a locked vault the nav state should be VaultLocked`() { mutableUserStateFlow.tryEmit( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index cc299470e..7b29b9722 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.core.net.toUri import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.autofill.fido2.manager.Fido2CompletionManager import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl @@ -80,6 +81,9 @@ class VaultItemListingScreenTest : BaseComposeTest() { every { shareText(any()) } just runs every { launchUri(any()) } just runs } + private val fido2CompletionManager: Fido2CompletionManager = mockk { + every { completeFido2Create(any()) } just runs + } private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val viewModel = mockk(relaxed = true) { @@ -95,6 +99,7 @@ class VaultItemListingScreenTest : BaseComposeTest() { VaultItemListingScreen( viewModel = viewModel, intentManager = intentManager, + fido2CompletionManager = fido2CompletionManager, onNavigateBack = { onNavigateBackCalled = true }, onNavigateToVaultItem = { onNavigateToVaultItemId = it }, onNavigateToVaultAddItemScreen = { onNavigateToVaultAddItemScreenCalled = true }, @@ -1555,6 +1560,7 @@ private fun createDisplayItem(number: Int): VaultItemListingState.DisplayItem = ), optionsTestTag = "SendOptionsButton", isAutofill = false, + isFido2Creation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = null, ) @@ -1576,6 +1582,7 @@ private fun createCipherDisplayItem(number: Int): VaultItemListingState.DisplayI ), optionsTestTag = "CipherOptionsButton", isAutofill = false, + isFido2Creation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = null, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 663149f4f..39ec8f2fe 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting +import android.content.pm.SigningInfo import android.net.Uri import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test @@ -9,6 +10,9 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult +import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest +import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManagerImpl import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData @@ -45,7 +49,9 @@ import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType +import io.mockk.Ordering import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -120,6 +126,9 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { every { getActivePolicies(type = PolicyTypeJson.DISABLE_SEND) } returns emptyList() every { getActivePoliciesFlow(type = PolicyTypeJson.DISABLE_SEND) } returns emptyFlow() } + private val fido2CredentialManager: Fido2CredentialManager = mockk { + coEvery { validateOrigin(any()) } returns Fido2ValidateOriginResult.Success + } private val initialState = createVaultItemListingState() private val initialSavedStateHandle = createSavedStateHandleWithVaultItemListingType( @@ -136,6 +145,35 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { } } + @Test + fun `initial dialog state should be correct when fido2Request is present`() = runTest { + val fido2CredentialRequest = Fido2CredentialRequest( + "mockUserId", + "{}", + "com.x8bit.bitwarden", + SigningInfo(), + origin = null, + ) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest = fido2CredentialRequest, + ) + + val viewModel = createVaultItemListingViewModel() + + viewModel.stateFlow.test { + assertEquals( + initialState.copy( + fido2CredentialRequest = fido2CredentialRequest, + dialogState = VaultItemListingState.DialogState.Loading( + message = R.string.loading.asText(), + ), + shouldFinishOnComplete = true, + ), + awaitItem(), + ) + } + } + @Test fun `on LockAccountClick should call lockVault for the given account`() { val accountUserId = "userId" @@ -933,6 +971,65 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } + @Suppress("MaxLineLength") + @Test + fun `vaultDataStateFlow Loaded with items and fido2 filtering should update ViewState to Content with filtered data`() = + runTest { + setupMockUri() + + coEvery { + fido2CredentialManager.validateOrigin(any()) + } returns Fido2ValidateOriginResult.Success + + val cipherView1 = createMockCipherView(number = 1) + val cipherView2 = createMockCipherView(number = 2) + + mockFilteredCiphers = listOf(cipherView1) + + val fido2CredentialRequest = Fido2CredentialRequest( + userId = "activeUserId", + requestJson = "{}", + packageName = "com.x8bit.bitwarden", + signingInfo = SigningInfo(), + origin = "mockOrigin", + ) + + specialCircumstanceManager.specialCircumstance = + SpecialCircumstance.Fido2Save( + fido2CredentialRequest = fido2CredentialRequest, + ) + val dataState = DataState.Loaded( + data = VaultData( + cipherViewList = listOf(cipherView1, cipherView2), + folderViewList = listOf(createMockFolderView(number = 1)), + collectionViewList = listOf(createMockCollectionView(number = 1)), + sendViewList = listOf(createMockSendView(number = 1)), + ), + ) + + val viewModel = createVaultItemListingViewModel() + + mutableVaultDataStateFlow.value = dataState + + assertEquals( + createVaultItemListingState( + viewState = VaultItemListingState.ViewState.Content( + displayCollectionList = emptyList(), + displayItemList = listOf( + createMockDisplayItemForCipher(number = 1) + .copy(isFido2Creation = true), + ), + displayFolderList = emptyList(), + ), + ) + .copy( + fido2CredentialRequest = fido2CredentialRequest, + shouldFinishOnComplete = true, + ), + viewModel.stateFlow.value, + ) + } + @Test fun `vaultDataStateFlow Loaded with empty items should update ViewState to NoItems`() = runTest { @@ -1376,6 +1473,242 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { ) } + @Test + fun `Fido2Request should be evaluated before observing vault data`() { + val fido2CredentialRequest = Fido2CredentialRequest( + "mockUserId", + "{}", + "com.x8bit.bitwarden", + SigningInfo(), + origin = "com.x8bit.bitwarden", + ) + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest, + ) + + createVaultItemListingViewModel() + + coVerify(ordering = Ordering.ORDERED) { + fido2CredentialManager.validateOrigin(fido2CredentialRequest) + vaultRepository.vaultDataStateFlow + } + } + + @Test + fun `Fido2ValidateOriginResult should update dialog state on Unknown error`() = runTest { + val fido2CredentialRequest = Fido2CredentialRequest( + userId = "mockUserId", + requestJson = "{}", + packageName = "com.x8bit.bitwarden", + signingInfo = SigningInfo(), + origin = null, + ) + + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest = fido2CredentialRequest, + ) + + coEvery { + fido2CredentialManager.validateOrigin(fido2CredentialRequest) + } returns Fido2ValidateOriginResult.Error.Unknown + + val viewModel = createVaultItemListingViewModel() + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + R.string.an_error_has_occurred.asText(), + R.string.generic_error_message.asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `Fido2ValidateOriginResult should update dialog state on PrivilegedAppNotAllowed error`() = + runTest { + val fido2CredentialRequest = Fido2CredentialRequest( + userId = "mockUserId", + requestJson = "{}", + packageName = "com.x8bit.bitwarden", + signingInfo = SigningInfo(), + origin = null, + ) + + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest = fido2CredentialRequest, + ) + + coEvery { + fido2CredentialManager.validateOrigin(fido2CredentialRequest) + } returns Fido2ValidateOriginResult.Error.PrivilegedAppNotAllowed + + val viewModel = createVaultItemListingViewModel() + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + R.string.an_error_has_occurred.asText(), + R.string.passkey_operation_failed_because_browser_is_not_privileged.asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `Fido2ValidateOriginResult should update dialog state on PrivilegedAppSignatureNotFound error`() = + runTest { + val fido2CredentialRequest = Fido2CredentialRequest( + userId = "mockUserId", + requestJson = "{}", + packageName = "com.x8bit.bitwarden", + signingInfo = SigningInfo(), + origin = null, + ) + + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest = fido2CredentialRequest, + ) + + coEvery { + fido2CredentialManager.validateOrigin(fido2CredentialRequest) + } returns Fido2ValidateOriginResult.Error.PrivilegedAppSignatureNotFound + + val viewModel = createVaultItemListingViewModel() + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + R.string.an_error_has_occurred.asText(), + R.string.passkey_operation_failed_because_browser_signature_does_not_match.asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `Fido2ValidateOriginResult should update dialog state on PasskeyNotSupportedForApp error`() = + runTest { + val fido2CredentialRequest = Fido2CredentialRequest( + userId = "mockUserId", + requestJson = "{}", + packageName = "com.x8bit.bitwarden", + signingInfo = SigningInfo(), + origin = null, + ) + + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest = fido2CredentialRequest, + ) + + coEvery { + fido2CredentialManager.validateOrigin(fido2CredentialRequest) + } returns Fido2ValidateOriginResult.Error.PasskeyNotSupportedForApp + + val viewModel = createVaultItemListingViewModel() + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + R.string.an_error_has_occurred.asText(), + R.string.passkeys_not_supported_for_this_app.asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `Fido2ValidateOriginResult should update dialog state on ApplicationNotFound error`() = + runTest { + val fido2CredentialRequest = Fido2CredentialRequest( + userId = "mockUserId", + requestJson = "{}", + packageName = "com.x8bit.bitwarden", + signingInfo = SigningInfo(), + origin = null, + ) + + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest = fido2CredentialRequest, + ) + + coEvery { + fido2CredentialManager.validateOrigin(fido2CredentialRequest) + } returns Fido2ValidateOriginResult.Error.ApplicationNotFound + + val viewModel = createVaultItemListingViewModel() + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + R.string.an_error_has_occurred.asText(), + R.string.passkey_operation_failed_because_app_not_found_in_asset_links.asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `Fido2ValidateOriginResult should update dialog state on AssetLinkNotFound error`() = + runTest { + val fido2CredentialRequest = Fido2CredentialRequest( + userId = "mockUserId", + requestJson = "{}", + packageName = "com.x8bit.bitwarden", + signingInfo = SigningInfo(), + origin = null, + ) + + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest = fido2CredentialRequest, + ) + + coEvery { + fido2CredentialManager.validateOrigin(fido2CredentialRequest) + } returns Fido2ValidateOriginResult.Error.AssetLinkNotFound + + val viewModel = createVaultItemListingViewModel() + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + R.string.an_error_has_occurred.asText(), + R.string.passkey_operation_failed_because_of_missing_asset_links.asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `Fido2ValidateOriginResult should update dialog state on ApplicationNotVerified error`() = + runTest { + val fido2CredentialRequest = Fido2CredentialRequest( + userId = "mockUserId", + requestJson = "{}", + packageName = "com.x8bit.bitwarden", + signingInfo = SigningInfo(), + origin = null, + ) + + specialCircumstanceManager.specialCircumstance = SpecialCircumstance.Fido2Save( + fido2CredentialRequest = fido2CredentialRequest, + ) + + coEvery { + fido2CredentialManager.validateOrigin(fido2CredentialRequest) + } returns Fido2ValidateOriginResult.Error.ApplicationNotVerified + + val viewModel = createVaultItemListingViewModel() + + assertEquals( + VaultItemListingState.DialogState.Fido2CreationFail( + R.string.an_error_has_occurred.asText(), + R.string.passkey_operation_failed_because_app_could_not_be_verified.asText(), + ), + viewModel.stateFlow.value.dialogState, + ) + } + @Suppress("CyclomaticComplexMethod") private fun createSavedStateHandleWithVaultItemListingType( vaultItemListingType: VaultItemListingType, @@ -1433,6 +1766,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() { cipherMatchingManager = cipherMatchingManager, specialCircumstanceManager = specialCircumstanceManager, policyManager = policyManager, + fido2CredentialManager = fido2CredentialManager, ) @Suppress("MaxLineLength") diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt index 5a46d69f3..a42f940e1 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt @@ -393,6 +393,7 @@ class VaultItemListingDataExtensionsTest { isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, autofillSelectionData = null, + fido2CreationData = null, hasMasterPassword = true, ) @@ -467,6 +468,7 @@ class VaultItemListingDataExtensionsTest { type = AutofillSelectionData.Type.LOGIN, uri = null, ), + fido2CreationData = null, hasMasterPassword = true, ) @@ -518,6 +520,7 @@ class VaultItemListingDataExtensionsTest { isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, autofillSelectionData = null, + fido2CreationData = null, hasMasterPassword = true, ), ) @@ -536,6 +539,7 @@ class VaultItemListingDataExtensionsTest { isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, autofillSelectionData = null, + fido2CreationData = null, hasMasterPassword = true, ), ) @@ -552,6 +556,7 @@ class VaultItemListingDataExtensionsTest { isIconLoadingDisabled = false, baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl, autofillSelectionData = null, + fido2CreationData = null, hasMasterPassword = true, ), ) @@ -571,6 +576,7 @@ class VaultItemListingDataExtensionsTest { type = AutofillSelectionData.Type.LOGIN, uri = "https://www.test.com", ), + fido2CreationData = null, hasMasterPassword = true, ), ) @@ -710,6 +716,7 @@ class VaultItemListingDataExtensionsTest { autofillSelectionData = null, itemListingType = VaultItemListingState.ItemListingType.Vault.Folder("1"), vaultFilterType = VaultFilterType.AllVaults, + fido2CreationData = null, hasMasterPassword = true, ) @@ -750,6 +757,7 @@ class VaultItemListingDataExtensionsTest { autofillSelectionData = null, itemListingType = VaultItemListingState.ItemListingType.Vault.Collection("mockId-1"), vaultFilterType = VaultFilterType.AllVaults, + fido2CreationData = null, hasMasterPassword = true, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt index 72be62e78..ae40f9d6c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt @@ -63,6 +63,7 @@ fun createMockDisplayItemForCipher( ), optionsTestTag = "CipherOptionsButton", isAutofill = false, + isFido2Creation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = "LoginCipherIcon", ) @@ -100,6 +101,7 @@ fun createMockDisplayItemForCipher( ), optionsTestTag = "CipherOptionsButton", isAutofill = false, + isFido2Creation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = "SecureNoteCipherIcon", ) @@ -142,6 +144,7 @@ fun createMockDisplayItemForCipher( ), optionsTestTag = "CipherOptionsButton", isAutofill = false, + isFido2Creation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = "CardCipherIcon", ) @@ -176,6 +179,7 @@ fun createMockDisplayItemForCipher( ), optionsTestTag = "CipherOptionsButton", isAutofill = false, + isFido2Creation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = "IdentityCipherIcon", ) @@ -224,6 +228,7 @@ fun createMockDisplayItemForSend( ), optionsTestTag = "SendOptionsButton", isAutofill = false, + isFido2Creation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = null, ) @@ -262,6 +267,7 @@ fun createMockDisplayItemForSend( ), optionsTestTag = "SendOptionsButton", isAutofill = false, + isFido2Creation = false, shouldShowMasterPasswordReprompt = false, iconTestTag = null, )