Compare commits

...

266 Commits

Author SHA1 Message Date
Dave Severns
17c579bfc2 PM-11387 on new create account with email verification, attempt login… (#3842) 2024-08-29 14:07:42 -04:00
Dave Severns
3c39d8beac PM-11224 Add menu to update feature flags with overridden values in real time (#3838) 2024-08-29 14:07:21 -04:00
Carlos Gonçalves
2a057bb1fb [PM-10762] Remove Passkey button should be hidden when I have Can View permission (#3829) 2024-08-29 16:40:01 +01:00
Patrick Honkonen
f778d7ecd1 [PM-10902] Base64 encode sensitive 2FA nav args (#3841) 2024-08-29 08:45:54 -04:00
Dave Severns
4c983525d3 PM-11310 handle email registration special circumstance after successful login (#3831) 2024-08-28 13:27:58 -04:00
Dave Severns
e32a9f303d PM-11394 String parse issue with app link (#3839) 2024-08-27 17:02:27 -04:00
David Perez
522e3bb939 PM-11354: TDE unlock since we already have the correct key from the identity service (#3835) 2024-08-27 08:54:51 -05:00
David Perez
0676cf8826 Update internal BitwardenTextButton padding to be consistent with all Bitwarden Buttons (#3834) 2024-08-26 17:10:02 -05:00
David Perez
5173dfd424 Carousel buttons should be full width (#3833) 2024-08-26 17:09:39 -05:00
David Perez
5bc31448b4 Update Firebase BOM to 33.2.0 (#3832) 2024-08-26 14:20:59 -05:00
David Perez
e9a7136a9a PM-10899: Only display the TDE UI if a selection has not yet been made (#3823) 2024-08-26 12:53:35 -05:00
David Perez
c36d0851ca Update the compose BOM (2024.08.00) (#3830) 2024-08-26 12:53:17 -05:00
Wu Nan
ace5f19375 [PM-11307] Fix typo in method name: shouldShouldRequestPermissionRationale (#3821) 2024-08-26 10:58:19 -04:00
A. Bubnov
88b40cfd10 [PM-10685] Support keyboard Done event as CTA Unlock on Pin\Master Password unlock screen (#3691) 2024-08-26 10:57:48 -04:00
David Perez
38e693f92c Simplify manual unlock check (#3824) 2024-08-26 09:07:23 -05:00
mpbw2
9dbb40f33b Add My Vault and Password Generator Quick Settings tiles (#3764) 2024-08-26 09:53:10 -04:00
Dave Severns
76a3265bbb PM-10692 pass a generated password back to the complete registration … (#3806) 2024-08-26 08:56:28 -04:00
David Perez
666c165b6f PM-10899: Fix user not being logged out properly on app restart (#3822) 2024-08-23 13:49:49 -05:00
Dave Severns
9db09c18cc [PM-11270] hide new UI in complete registration screen behind flag pt. 2 (#3812) 2024-08-23 12:52:21 -04:00
David Perez
b7330392cc PM-11299: Update the userState to properly parse the hasManageResetPasswordPermission flag (#3820) 2024-08-23 11:11:20 -05:00
David Perez
162da64567 Minor formatting an import cleanup (#3819) 2024-08-23 10:49:59 -05:00
github-actions[bot]
09f497ca9b Autosync Crowdin Translations (#3815) 2024-08-23 10:25:44 -04:00
André Bispo
87d7143cc8 [PM-6702] AppLink new redirect path (#3814) 2024-08-23 13:19:19 +00:00
David Perez
23bcfad717 PM-11273: Update the 'useKeyConnector' with 'keyConnectorEnabled' (#3813) 2024-08-22 17:05:46 -05:00
David Perez
f1f16cfee5 PM-11265: Remove the leave organization API (#3811) 2024-08-22 15:17:54 -05:00
Dave Severns
82d3b44712 [PM-11270] Hide all new UI behind onboarding flow flag. (#3810) 2024-08-22 16:06:54 -04:00
David Perez
b56a21b6e5 PM-10917: Fix crash caused when adding an item from a collection (#3809) 2024-08-22 13:48:46 -05:00
David Perez
eb2ba8e598 PM-11264: Ensure user has valid timeout action after migrating to Key Connector (#3807) 2024-08-22 13:48:26 -05:00
David Perez
91f039ecb6 Simplify common login helper methods (#3805) 2024-08-22 11:22:07 -05:00
Dave Severns
0d6aeee870 PM-10617 modify pw strength indicator to show min chars if required. (#3793) 2024-08-22 11:13:23 -04:00
David Perez
a0a5070ac7 PM-11254: Add logic for logging in with Key Connector (#3802) 2024-08-21 16:13:36 -05:00
David Perez
e7bd966e94 PM-11256: Add RootNav logic to display Remove Password Screen (#3803) 2024-08-21 15:53:27 -05:00
Dave Severns
075956ce17 PM-10617 + PM-10637 update complete registration screen to match new onboarding design (#3787) 2024-08-21 15:32:28 -04:00
David Perez
13b256d4e9 PM-11155: Add logic for handling remove password flow (#3801) 2024-08-21 14:30:27 -05:00
David Perez
5761e9510a PM-11248: Add isUsingKeyConnector flag to UserState (#3798) 2024-08-21 14:24:34 -05:00
David Perez
3b3b9ef33b Fix IllegalArgumentException in test (#3799) 2024-08-21 12:33:33 -05:00
David Perez
17fd3ec0f0 PM-11226: Wrap Key Connector APIs (#3794) 2024-08-21 12:26:20 -05:00
David Perez
43a6495b98 PM-11236: Add build type and flavor to the user agent (#3797) 2024-08-21 09:36:45 -05:00
Dave Severns
86dabea39f PM-11192 update check email screen to new design (#3788) 2024-08-20 18:17:03 -04:00
David Perez
8d08b5f7c5 PM-11223: Enable remote confg for email verification feature (#3792) 2024-08-20 15:19:23 -05:00
Matt Bishop
13c29c8296 Update public suffix list (#3790) 2024-08-20 14:18:00 -04:00
David Perez
eac5516a94 PM-11154: Create basic Remove Master Password UI (#3782) 2024-08-20 13:15:44 -05:00
renovate[bot]
88b674f54c [deps]: Lock file maintenance (#3786) 2024-08-20 12:27:51 -04:00
renovate[bot]
bcc24a2e25 [deps]: Update sonarsource/sonarcloud-github-action action to v3 (#3785) 2024-08-20 12:25:30 -04:00
renovate[bot]
e14f399e2d [deps]: Update github/codeql-action action to v3.26.3 (#3784) 2024-08-20 12:13:39 -04:00
André Bispo
ad2c575b39 [PM-9933] Update marketing copy (#3778) 2024-08-20 08:33:53 -04:00
Dave Severns
57c2e7ee4e [Pm 10616] create account start design (#3751) 2024-08-19 17:47:45 -04:00
Patrick Honkonen
55b57a605e [PM-10282] Update build artifact names (#3774) 2024-08-19 16:42:48 -04:00
David Perez
397c78b4af PM-11140: Update hasMasterPassword logic for key connectors (#3775) 2024-08-19 15:13:31 -05:00
David Perez
9e372c29d1 Update to the latest Bitwarden SDK (#3779) 2024-08-19 15:12:30 -05:00
David Perez
82fd7f01f8 PM-10954: Update the key connector APIs to use the correct url and responses (#3781) 2024-08-19 15:12:09 -05:00
Patrick Honkonen
a15b84a5bf [PM-10282] Default to last active account for passkey creation (#3780) 2024-08-19 15:10:31 -04:00
David Perez
5f46423638 Apply formatter to the app (#3777) 2024-08-19 13:43:45 -05:00
renovate[bot]
8aebd36465 [deps]: Update gradle minor (#3771) 2024-08-19 09:40:42 -04:00
renovate[bot]
b4f864d89c [deps]: Update kotlin (#3770) 2024-08-19 09:39:03 -04:00
Patrick Honkonen
8c8db78da6 [PM-10883] Support deserializing Forward Email service type details (#3739) 2024-08-19 09:02:57 -04:00
renovate[bot]
b18d9f53c6 [deps]: Lock file maintenance (#3772) 2024-08-19 12:59:25 +00:00
Dave Severns
7134d89352 PM-10986 explicitly keep AuthenticatedKeyConnectionApi to prevent cla… (#3765) 2024-08-16 15:45:03 -04:00
Patrick Honkonen
5a7dc198dd [PM-10884] Catch ProviderException when generating a secure key (#3733) 2024-08-16 15:13:41 -04:00
renovate[bot]
7dbfcfdea2 [deps]: Lock file maintenance (#3760) 2024-08-16 14:10:09 -04:00
renovate[bot]
b56ccd1bab [deps]: Update gradle/actions action to v4 (#3759) 2024-08-16 12:58:03 -04:00
renovate[bot]
f05828c87d [deps]: Update gh minor (#3758) 2024-08-16 12:31:59 -04:00
David Perez
48817f0fe4 Simplify error responses (#3762) 2024-08-16 15:07:56 +00:00
github-actions[bot]
3bed2581af Autosync Crowdin Translations (#3756) 2024-08-16 14:27:06 +00:00
André Bispo
acb125b2b9 [PM-6702] 6# Complete registration screen (#3622) 2024-08-16 15:16:36 +01:00
David Perez
72e5aedccd Rename APIs for extra specificity (#3755) 2024-08-16 09:04:10 -05:00
Shannon Draeker
9148a750a5 PM-10874: Prompt for biometrics after switching accounts (#3753) 2024-08-16 09:45:32 -04:00
David Perez
d4600c5c83 PM-10956: Add support for leave organization API (#3754) 2024-08-16 08:37:07 -05:00
David Perez
8094b3fd22 PM-10954: Add network APIs for key-connector (#3752) 2024-08-16 08:36:42 -05:00
David Perez
bd55b9ce72 Add helper function for static retrofit instances (#3749) 2024-08-15 15:26:12 -05:00
David Perez
4726cb743a PM-10936: Add account apis for key connectors (#3748) 2024-08-15 13:53:48 -05:00
André Bispo
244d259804 [PM-6702] 5# Check your email screen (#3621) 2024-08-15 18:25:45 +01:00
André Bispo
eab94dde79 [PM-6702] 4# Start registration screen (#3620) 2024-08-15 17:15:45 +01:00
David Perez
2bb921b592 All booleans stored are nullable for consistency (#3747) 2024-08-15 11:02:01 -05:00
David Perez
18b58e75f8 PM-10909: Add persistance layer for usersKeyConnector (#3740) 2024-08-15 10:34:30 -05:00
André Bispo
e2cd3867dd [PM-6702] 3# Open app from App Link to CompleteRegistration (#3619) 2024-08-15 14:28:35 +01:00
David Perez
524b9e9a08 Add logging for SDK functionality in debug only (#3738) 2024-08-14 16:10:19 -05:00
David Perez
4b35484abb Update to AGP 8.5.2 (#3736) 2024-08-14 15:33:03 -05:00
David Perez
d305dc3081 Remove unused dangerfile (#3735) 2024-08-14 15:32:36 -05:00
David Perez
dde90a251a Update WorkManager to 2.9.1 (#3737) 2024-08-14 15:32:13 -05:00
David Perez
516cd72f66 Fix a failing test (#3734) 2024-08-14 14:46:04 -05:00
David Perez
63884e8518 PM-10894: Add flag for disabling remote feature flag configuration (#3729) 2024-08-14 14:06:09 -05:00
David Perez
8a4d436f1f Remove API specific autofill configuration file (#3730) 2024-08-14 13:54:03 -05:00
Dave Severns
ab279e2264 PM-10851 make the default top app bar reactive (#3726) 2024-08-14 13:42:08 -04:00
Shannon Draeker
2876d75a21 PM-10874: Fix biometrics auto-prompt (#3728) 2024-08-14 11:48:58 -04:00
Patrick Honkonen
aaa0ce4ecd [PM-10664] Display server error message during 2FA login (#3719) 2024-08-14 11:30:05 -04:00
David Perez
499bc20850 PM-10878: Access parcelable data in a safe manor across SDK versions (#3727) 2024-08-14 10:28:01 -05:00
David Perez
2bed4986a1 PM-10855: Update the minimum SDK to API 29 (Android 10) (#3723) 2024-08-14 09:23:13 -05:00
Dave Severns
151b081161 PM-10619 screen to generate master password (#3721) 2024-08-13 16:58:51 -04:00
Shannon Draeker
e3371b7620 PM-8522: Fix vault tab nav bar title when logging in (#3710) 2024-08-13 12:55:51 -04:00
David Perez
551f948644 PM-10835: Make config request after environment update (#3720) 2024-08-13 11:34:33 -05:00
André Bispo
4bd81782c8 [PM-6702] 2# Region load in complete registration step (#3618) 2024-08-13 15:22:34 +01:00
Shannon Draeker
4dbcec85bb PM-10118: Remember generator types (#3708) 2024-08-13 09:27:54 -04:00
Patrick Honkonen
5a0b1caecd [PM-10696] Dismiss vault unlock keyboard (#3718) 2024-08-12 16:11:30 -04:00
Dave Severns
2b13151bd1 PM-10620 prevent account lockout tips screen (#3711) 2024-08-12 08:38:23 -04:00
David Perez
5e643e11fd PM-10243: Update carousel text (#3714) 2024-08-09 16:15:23 -05:00
Patrick Honkonen
2789b1cc37 [PM-10697] Auto-focus on PIN Dialog field (#3713) 2024-08-09 16:26:54 -04:00
David Perez
b7a47eb91e Add helper method for standardizing margins (#3712) 2024-08-09 14:59:21 -05:00
Dave Severns
06f6f19255 PM-10071 ensure that lowercase letters take priority over the upperca… (#3707) 2024-08-09 14:55:24 -04:00
André Bispo
e717183239 [PM-6702] 1# Add service calls for email verification (#3617) 2024-08-09 19:38:52 +01:00
David Perez
edb87202d2 PM-10628: Add pin unlock to SetupUnlockViewModel (#3709) 2024-08-09 12:09:52 -05:00
David Perez
9b808058f5 Allow the ShowShareSheet event to be launched after the screen is paused (#3706) 2024-08-09 09:58:47 -05:00
github-actions[bot]
89589aa907 Autosync Crowdin Translations (#3703)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2024-08-09 14:33:21 +00:00
David Perez
805fea630c Add logic for biometric unlock to SetupUnlockScreen (#3702) 2024-08-09 09:09:41 -05:00
David Perez
145f8adf0c PM-10621: Add the SetupUnlockScreen (#3699) 2024-08-08 16:18:29 -05:00
Dave Severns
6bb5ef7417 [PM-10618] MP guidance screen with info and clickable card to navigate … (#3697) 2024-08-08 16:53:56 -04:00
Carlos Gonçalves
722726882b [PM-9833] Allow passkey deletion edit view (#3654) 2024-08-08 21:17:09 +01:00
David Perez
9ed30d7913 Fix a minor parcelable warning (#3701) 2024-08-08 14:47:43 -05:00
David Perez
6c5c0c7c03 PM-10729: Add a helper method for determining if the app is in portrait orientation (#3698) 2024-08-08 12:24:12 -05:00
Dave Severns
a57a7e099c [PM-10065] Use appropriate back behavior depending on how you are take to auth approval screen (#3695) 2024-08-08 11:37:20 -04:00
Shannon Draeker
f17289a104 PM-10242 PM-10243 PM-10244 PM-10245 PM-10246: Welcome carousel (#3657) 2024-08-07 16:12:46 -04:00
David Perez
e598fe5714 PM-10621: Create common biometrics and pin unlock UI elements (#3696) 2024-08-07 14:50:40 -05:00
Patrick Honkonen
be534f940b [PM-10670] Prompt for PIN creation during passkey user verification (#3694) 2024-08-07 14:17:13 -04:00
David Perez
782b474e54 BIT-2437: Add mitigation logic for bad encryption key (#3426) 2024-08-07 11:07:17 -05:00
Álison Fernandes
d8471b41ca [PM-10686] Change the background colour of the app launcher to Bitwarden's blue (#3693) 2024-08-07 16:45:56 +01:00
David Perez
9484eebc70 Consolidate unlock vault functionality for auth into a single helper method (#3690) 2024-08-07 10:45:04 -05:00
A. Bubnov
22dae88b42 [PM-10024] Force focus on Master Password or Pin input field (#3601) 2024-08-07 11:34:32 -04:00
David Perez
23066769a1 Add option to retrieve feature flag synchronously (#3692) 2024-08-07 10:13:52 -05:00
Shannon Draeker
59ba585048 PM-10122: Autofocus on PIN or password field (#3678) 2024-08-06 15:49:39 -04:00
David Perez
6c50cbf558 Add onboarding feature flag (#3689) 2024-08-06 14:44:49 -05:00
Dave Severns
0085388446 [PM-10071] Sort search items with same logic as displayed items (#3683) 2024-08-06 15:29:33 -04:00
Dave Severns
18cd66a34b PM-9532: pt2. separate vault unlock logic and fail out on error during login. (#3609) 2024-08-06 14:42:05 -04:00
Dave Severns
a090000826 [PM-10058] Non-remembered device TDE issue in same session (#3631) 2024-08-06 13:34:04 -04:00
David Perez
af82261fba Minor formating for the VaultSdkSource (#3688) 2024-08-06 12:09:56 -05:00
David Perez
b15371bfce Remove a suppression from gradle properties that is no longer needed (#3687) 2024-08-06 11:38:06 -05:00
Patrick Honkonen
1e5bee2917 [PM-10644] Re-prompt master password for protected passkeys (#3682) 2024-08-06 15:43:12 +00:00
David Perez
3819916241 PM-10241: Add the onboarding carousel feature flag (#3686) 2024-08-06 10:22:46 -05:00
David Perez
e7c69fc089 Allow null network responses for 204s (#3685) 2024-08-06 10:13:57 -05:00
André Bispo
994a577600 [PM-9401] Server feature flags manager (#3656) 2024-08-06 16:00:22 +01:00
David Perez
02167024b1 Minor formatting and clean up for ResultCall (#3684) 2024-08-06 09:52:20 -05:00
Dave Severns
f110687e76 PM-10066 don't prompt for MP if the user does not have one (#3633) 2024-08-05 17:34:46 -04:00
Dave Severns
abeb60e237 [PM-10645] add nav bar padding in bw scaffold for FAB (#3679) 2024-08-05 16:57:21 -04:00
Patrick Honkonen
4c8164954d [PM-10556] Move FIDO 2 intent filter to main manifest (#3677) 2024-08-05 11:26:46 -04:00
Patrick Honkonen
7f13822f15 [PM-9927] Sort Sends alphabetically (#3665) 2024-08-05 10:32:35 -04:00
Patrick Honkonen
31bf696e7e [PM-10373] Fix FIDO 2 credential creation from unprivileged apps (#3658) 2024-08-05 09:28:37 -05:00
renovate[bot]
f46d12c7b1 [deps]: Update com.google.devtools.ksp to v2.0.0-1.0.24 (#3672)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-08-05 09:25:12 -05:00
renovate[bot]
ad240a9a19 [deps]: Update gradle minor (#3674)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-08-05 08:53:21 -05:00
renovate[bot]
93107ec6a3 [deps]: Lock file maintenance (#3675) 2024-08-05 13:07:33 +00:00
renovate[bot]
056eb7fdd5 [deps]: Update gh minor (#3673) 2024-08-05 13:06:01 +00:00
David Perez
bbe50ae0ff PM-10559: Add logic to re-evaluate invalid password fields for Autofill (#3668) 2024-08-02 16:52:39 -05:00
Brian Yencho
aae7a6e895 PM-10528: Fix user switching issue due to rapid Activity recreation when locking (#3669) 2024-08-02 16:39:23 -05:00
github-actions[bot]
32b260ca9f Autosync Crowdin Translations (#3666) 2024-08-02 15:28:27 +00:00
Patrick Honkonen
0612a5834a [PM-9472] Update release notes generated for Firebase (#3664) 2024-08-02 10:48:33 -04:00
Shannon Draeker
055fbc1277 PM-10094: Disable double-navigation by default (#3660) 2024-08-01 15:31:04 -06:00
Patrick Honkonen
d0edca67c5 [PM-10441] Fix memory exception during CI builds (#3662) 2024-07-31 21:23:25 -04:00
Patrick Honkonen
d558f94e40 [PM-10440] Enable minification on beta build variants (#3661) 2024-07-31 22:19:52 +00:00
Patrick Honkonen
1f8d50e788 [PM-10428] Default UserVerificationRequirement to PREFERRED (#3659) 2024-07-31 17:41:47 -04:00
Patrick Honkonen
260b3bfb1b [PM-9803] Enable Credential Manager on production builds (#3651) 2024-07-31 09:11:09 -04:00
David Perez
d5e0ebee12 Remove unused Json object from VaultRepository (#3653) 2024-07-30 17:44:05 -05:00
David Perez
6d22ee9550 PM-10379: Update the timeout action logic to occur immediately after requirements are met (#3652) 2024-07-30 17:43:54 -05:00
Shannon Draeker
82096e0625 PM-9406: Add passkey management to autofill settings (#3392) 2024-07-30 16:10:09 -06:00
André Bispo
646566edd8 [PM-9875] Server configurations (#3645) 2024-07-30 20:23:33 +01:00
Patrick Honkonen
b26e1a082e [PM-9410] Filter matching FIDO 2 credentials after vault unlock (#3648) 2024-07-30 13:45:36 -04:00
Patrick Honkonen
deb8f811e5 [PM-9410] Implement FIDO 2 Get Credentials completion (#3639) 2024-07-29 16:50:20 -04:00
Shannon Draeker
0e90bbb905 PM-8522: Vault tab bar title for organization users (#3632) 2024-07-29 14:19:08 -06:00
David Perez
58a91c15aa PM-10140: Update the VaultSdkSource and VaultDiskSource to use parallelization when processing heavier loads (#3649) 2024-07-29 15:10:38 -05:00
David Perez
1daddbc905 PM-10140: Update Autofill classes to be singletons (#3647) 2024-07-29 13:13:08 -05:00
David Perez
b6af48fb3b PM-10140: Allow for the vault data to have a pending state by default when data is already present (#3646) 2024-07-29 13:06:08 -05:00
David Perez
3ff70b4598 PM-10140: Update looping SDK calls to use single instance of client (#3644) 2024-07-29 11:10:22 -05:00
Patrick Honkonen
b0079fca5c [PM-9410] Introduce FIDO 2 Get Credentials Request special circumstance (#3637) 2024-07-29 11:54:23 -04:00
David Perez
39250e5cb4 PM-10140: Add caching for large string resources to avoid delays and reduce timeout when retrieving ciphers (#3638) 2024-07-29 10:46:13 -05:00
Patrick Honkonen
74132de8ed [PM-9409] Authenticate selected FIDO 2 credential (#3630) 2024-07-26 13:18:29 -04:00
David Perez
a6bbde2bed PM-9135: Update host matching to include optional port value (#3623) 2024-07-26 10:33:15 -05:00
github-actions[bot]
544eabfaa3 Autosync Crowdin Translations (#3634)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2024-07-26 15:26:46 +00:00
Shannon Draeker
680ebc2e47 PM-9681: Setup Bitwarden PIN on add edit view (#3627) 2024-07-26 09:06:49 -06:00
Patrick Honkonen
b0f0c0f33b [PM-9409] Add FIDO 2 authentication to credential manager (#3629) 2024-07-25 15:46:26 -04:00
Shannon Draeker
c09fe554bc PM-9681: Setup Bitwarden PIN (#3626) 2024-07-25 10:59:20 -06:00
A. Bubnov
5c2ac2e037 [PM-10067] Show content on Vault screen when we have trashed items only (#3624) 2024-07-25 10:54:21 -04:00
Patrick Honkonen
793971c3a3 [PM-9409] Complete FIDO 2 assertion with appropriate response (#3615) 2024-07-25 10:33:14 -04:00
Dave Severns
8ffd14c2fb [PM-9927] Sort order update (#3625) 2024-07-24 16:17:58 -04:00
Patrick Honkonen
da3d834a91 [PM-9409] Define FIDO 2 assertion Special Circumstance (#3612) 2024-07-24 16:01:22 -04:00
Shannon Draeker
b48837e13c PM-9682: Verify with PIN on add edit view (#3610) 2024-07-24 09:40:25 -06:00
Dave Severns
b44a320dc8 PM-9937 an existing email should be able to add account from a different hosted instance. (#3613) 2024-07-23 15:45:56 -04:00
Patrick Honkonen
d2432f7cf7 Extract FIDO 2 user verification enum (#3614) 2024-07-23 15:33:20 -04:00
Shannon Draeker
7cf7536857 PM-9682: Verify with PIN on item listing (#3600) 2024-07-23 10:53:44 -06:00
David Perez
779cd1356a Update the HOST type cipher matching to ignore the port (#3611) 2024-07-23 10:38:55 -05:00
Dave Severns
05dc220303 PM-9532 pt. 1 small refactor of login success steps (#3599) 2024-07-23 09:52:10 -04:00
David Perez
21c1fa7131 Provide autofill response data even if focused field is not fillable (#3598) 2024-07-22 17:10:48 -05:00
Shannon Draeker
2475bf5a41 PM-9684: Verify with master password on add edit view (#3586) 2024-07-22 15:20:00 -06:00
Shannon Draeker
62154f5261 PM-9408: Show bottom sheet with passkey options (#3444) 2024-07-22 14:07:22 -06:00
renovate[bot]
0e44b21361 [deps]: Update kotlin (#3591)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-07-22 13:19:24 -05:00
David Perez
f3d28551b1 BIT-877: Mockk update fixed a disabled test (#3597) 2024-07-22 11:39:06 -05:00
renovate[bot]
1927630acb [deps]: Update io.mockk:mockk to v1.13.12 (#3590) 2024-07-22 11:39:56 -04:00
renovate[bot]
975fa91d36 [deps]: Lock file maintenance (#3592) 2024-07-22 11:39:12 -04:00
renovate[bot]
7218ca2477 [deps]: Update github/codeql-action action to v3.25.13 (#3589) 2024-07-22 09:14:07 -04:00
Shannon Draeker
ee87d8ada8 PM-9684: Verify with master password on item listing (#3585) 2024-07-19 15:20:55 -06:00
Patrick Honkonen
8a381d8682 Refactor PublicKeyCredentialCreationOptions (#3584) 2024-07-19 15:08:54 -04:00
David Perez
1fdfbac7b7 Add timeouts to operations that could hang (#3553) 2024-07-19 11:05:24 -05:00
github-actions[bot]
7fbc6ea4f3 Autosync Crowdin Translations (#3555)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2024-07-19 16:02:15 +00:00
Patrick Honkonen
7ddbc99add Cancel FIDO 2 registration job when cancellation occurs (#3583) 2024-07-19 11:44:33 -04:00
David Perez
4abf907dc5 Catch TransactionTooLargeExceptions in autofill (#3569) 2024-07-19 09:31:17 -05:00
David Perez
9ffc0360bd Update the Firebase BOM to 33.1.2 (#3552) 2024-07-18 15:50:48 -05:00
David Perez
c4365c0193 Update to AGP 8.5.1 (#3551) 2024-07-18 15:50:31 -05:00
Patrick Honkonen
1ea1e7918b [PM-9407] Confirm overwrite existing passkey in edit mode (#3542) 2024-07-18 16:53:15 +00:00
Patrick Honkonen
815e779475 [PM-9407] Confirm overwrite existing passkey on item listing (#3540) 2024-07-18 12:35:05 -04:00
renovate[bot]
d9f506dd8f [deps]: Update ubuntu to v22 (#3550) 2024-07-18 12:34:10 -04:00
renovate[bot]
4377921d20 [deps]: Update gh minor (#3549) 2024-07-18 12:33:15 -04:00
David Perez
775a73fe54 PM-9659: Do not show push notification permissions on FDroid (#3528) 2024-07-18 11:17:23 -05:00
David Perez
96324f01d7 All of the autofill processing happens in a job (#3545) 2024-07-17 15:05:11 -05:00
Dave Severns
7d18310f30 PM-8534 update the active account after a "soft logout" (#3456) 2024-07-17 14:06:51 -04:00
Dave Severns
3d584c84f2 [PM-9844] Android - Non-Premium Users Can Copy TOTP Code From Item Menu (#3539) 2024-07-17 14:06:18 -04:00
Dave Severns
f1c486bf9a [PM-9838] Custom field spacing on Add/Edit item screen (#3546) 2024-07-17 13:09:47 -04:00
Dave Severns
a5224c966c PM-9007: export vault copy (#3537) 2024-07-17 09:23:37 -04:00
Patrick Honkonen
9b19c71d95 [PM-8137] Perform FIDO 2 verification on item add/edit when required (#3532) 2024-07-16 17:02:16 -04:00
Patrick Honkonen
36270ec55a [PM-8137] Perform FIDO 2 verification on item listing when required (#3529) 2024-07-16 17:01:55 -04:00
Patrick Honkonen
94781bc1a9 [PM-9407] Create reusable overwrite passkey confirmation dialog (#3541) 2024-07-16 16:19:41 -04:00
Patrick Honkonen
93cde9bfdc Update Bitwarden SDK (#3538) 2024-07-16 13:34:33 -04:00
Patrick Honkonen
291af8d017 [PM-8137] Introduce FIDO 2 user verification to add edit item (#3450) 2024-07-16 10:56:12 -04:00
Dave Severns
b0ff0b9185 [Build Issue] use full source set to match fastfile test task (#3533) 2024-07-16 09:56:23 -04:00
Patrick Honkonen
7653d71b3d [PM-8137] Set initial FIDO 2 user verification state (#3463) 2024-07-16 09:02:59 -04:00
Patrick Honkonen
5ea2f1c736 [PM-8137] Introduce FIDO 2 user verification tracking (#3459) 2024-07-15 14:44:16 -04:00
Dave Severns
721c69619e PM-9017 updated the continue button state when switching 2FA method (#3530) 2024-07-15 14:32:40 -04:00
Patrick Honkonen
58937d4e20 Fix detekt issues (#3531) 2024-07-15 18:18:46 +00:00
Dave Severns
ed53abb29f PM-7495 perform client side check for invalid MP before account deletion (#3439) 2024-07-15 13:51:50 -04:00
Patrick Honkonen
53c5d11076 [PM-8137] Introduce FIDO 2 user verification to item listing screen (#3449) 2024-07-15 11:11:45 -04:00
David Perez
4d65230476 Update the detekt command for fastlane to cover more of the app (#3462) 2024-07-12 16:23:28 -05:00
David Perez
20d37e2f90 Hide inline autofill option when not supported (#3455) 2024-07-12 16:09:14 -05:00
Patrick Honkonen
101b807b5c Resolve detekt issues (#3460) 2024-07-12 20:54:48 +00:00
Matt Bishop
3931af096d Exclude tests from Sonar (#3457) 2024-07-12 16:43:36 -04:00
Patrick Honkonen
c6d05b4631 [PM-8137] Respond to SDK user verification callbacks implicitly (#3448) 2024-07-12 09:29:34 -05:00
Dave Severns
27747b6cb9 PM-8202 move dialog status to VM for restore item, add check for MP p… (#3436) 2024-07-12 09:56:40 -04:00
github-actions[bot]
dbf1d423e8 Autosync Crowdin Translations (#3453)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2024-07-12 10:29:34 +00:00
Shannon Draeker
eb771e9dfa PM-9439: Update cipher list item for passkeys (#3422) 2024-07-11 18:48:25 -06:00
Patrick Honkonen
a84694b100 [PM-8137] Introduce user verification prompt (#3447) 2024-07-11 22:22:54 +00:00
Patrick Honkonen
9b240ddf5f [PM-8137] Refactor FIDO 2 credential registration result object (#3445) 2024-07-11 18:04:24 -04:00
David Perez
c409132825 Update no network errors to be consistent and have a space inbetween concatenated text (#3446) 2024-07-11 15:00:30 -05:00
David Perez
f6f28f6a58 Add logic to find missing username fields (#3440) 2024-07-11 12:16:35 -05:00
Patrick Honkonen
b2300328e1 Refactor PublicKeyCredentialCreationOptions to match WebAuthn spec (#3443) 2024-07-11 12:43:51 -04:00
Patrick Honkonen
9205dbef59 Introduce utility function for displaying VaultAddEdit error message (#3438) 2024-07-10 21:56:36 +00:00
Patrick Honkonen
0651494393 Introduce utility function for clearing VaultAddEdit dialog state (#3437) 2024-07-10 15:57:43 -04:00
David Perez
4f3d42264d Add support for AutoCompleteTextView as an input field (#3435) 2024-07-10 11:40:46 -05:00
Patrick Honkonen
0cdae9bccf Upload F-Droid Beta artifacts to GitHub (#3428) 2024-07-09 17:50:46 -04:00
David Perez
5677373421 Use random ints for request codes to ensure no overlap (#3427) 2024-07-09 14:13:58 -05:00
David Perez
0dc606b470 Clean up some minor detekt issues (#3425) 2024-07-09 11:32:57 -05:00
David Perez
65237c18d7 Add ability to log non-fatal errors (#3417) 2024-07-09 10:02:36 -05:00
David Perez
adf7916a4c BIT-2446: Limit the number of inline autofill items that can be displayed (#3418) 2024-07-09 09:38:40 -05:00
David Perez
01b786fcf9 Update to the latest Bitwarden SDK (#3419) 2024-07-08 17:25:57 -05:00
Shannon Draeker
99c50a3fc8 PM-9443: Update empty search state (#3409) 2024-07-08 14:26:34 -06:00
David Perez
e9057cb866 BIT-2440, BIT-2441: Clean up the way we update the account info after token refresh (#3416) 2024-07-08 10:44:43 -05:00
David Perez
4b0c6ad911 PM-9077: Ensure each PendingIntent for inline autofill uses unique requestCode (#3401) 2024-07-08 08:51:01 -05:00
renovate[bot]
263b401dc6 [deps]: Update kotlin (#3412) 2024-07-08 09:47:58 -04:00
renovate[bot]
cae000ef5f [deps]: Update gradle minor (#3414) 2024-07-08 09:43:01 -04:00
renovate[bot]
5856403c66 [deps]: Update crowdin/github-action action to v2 (#3415) 2024-07-08 09:41:42 -04:00
renovate[bot]
02b5542388 [deps]: Update gh minor (#3411) 2024-07-08 09:41:02 -04:00
Dave Severns
870de652be BIT-2431 add condition to only update labelTextWidth on initial layout (#3399) 2024-07-05 14:48:49 -04:00
Dave Severns
f13679cd2c BIT-2398 if the org associated with a cipher uses TOTP enable the aut… (#3398) 2024-07-05 14:40:06 -04:00
github-actions[bot]
9e0e07967f Autosync Crowdin Translations (#3402) 2024-07-05 15:53:30 +00:00
Patrick Honkonen
d182b4edf1 [PM-8137] Allow registering a passkey to a new cipher (#3329) 2024-07-05 11:35:28 -04:00
Patrick Honkonen
a2572d996b Include Livefront in Firebase distributions (#3400) 2024-07-03 14:37:33 -05:00
David Perez
c3d2389829 BIT-2439: Handle invalid patterns when processing regular expression matching (#3397) 2024-07-03 09:56:09 -05:00
David Perez
074979095b Update detekt config and update suppressions (#3396) 2024-07-02 17:19:56 -05:00
David Perez
ba95a53ebf BIT-2443: Fix crash caused by extra blank spaces in name (#3395) 2024-07-02 16:24:03 -05:00
David Perez
bb6a7af423 BIT-2442: check type before extracting autofill text (#3394) 2024-07-02 15:18:25 -05:00
David Perez
b181d0d026 BIT-2438: Update push notification processing logic to be more lenient (#3393) 2024-07-02 14:53:29 -05:00
David Perez
f5039d72b9 Update NotificationPayload to handle null values (#3391) 2024-07-01 17:01:07 -05:00
David Perez
44e2596a30 Update the Lifecycle library (#3390) 2024-07-01 14:12:53 -05:00
David Perez
32e3f1e9ba PM-9081: Should cancel the job not the scope when managing autofill requests (#3389) 2024-07-01 13:09:50 -05:00
Patrick Honkonen
d7032b8475 Distribute builds to Firebase on push events to main (#3388) 2024-07-01 11:31:10 -04:00
github-actions[bot]
7d03b62dd7 Autosync Crowdin Translations (#3381) 2024-06-28 19:00:40 -04:00
Álison Fernandes
fd7ceef2ee [PM-9340] Improve Bug template and adds a config link (#3380)
Co-authored-by: Matt Bishop <mbishop@bitwarden.com>
2024-06-28 09:08:19 -04:00
Patrick Honkonen
581539b0d0 [PM-8985] Fix typo in fastfile (#3379) 2024-06-28 00:00:31 +01:00
Álison Fernandes
fae92be38a [PM-9340] Fix bug template syntax error by adding quotes to label (#3378) 2024-06-27 18:38:15 -04:00
Álison Fernandes
9f354bf248 [PM-9340] Fix template syntax error (#3377) 2024-06-27 23:13:43 +01:00
Patrick Honkonen
fc84a89dd2 [PM-8985] Publish beta builds to internal track (#3376) 2024-06-27 18:08:54 -04:00
Álison Fernandes
1d6707ef82 [PM-9340] Updates the bug template and readme to direct users to the right repos (#3374) 2024-06-27 22:58:33 +01:00
Patrick Honkonen
82e2c82666 [PM-8985] Fetch all tags when checking out repo for build job (#3371) 2024-06-27 17:15:17 -04:00
612 changed files with 44600 additions and 6807 deletions

View File

@@ -1,4 +1,4 @@
name: Android Bug Report
name: Android Beta Bug Report
description: File a bug report
labels: [ bug ]
body:
@@ -7,7 +7,19 @@ body:
value: |
Thanks for taking the time to fill out this bug report!
> [!WARNING]
> This is the new native Bitwarden Beta app repository. For the publicly available apps in App Store / Play Store, submit your report in [bitwarden/mobile](https://github.com/bitwarden/mobile)
Please do not submit feature requests. The [Community Forums](https://community.bitwarden.com) has a section for submitting, voting for, and discussing product feature requests.
- type: checkboxes
id: beta
attributes:
label: Bitwarden Beta
options:
- label: "I'm using the new native Bitwarden Beta app and I'm aware that legacy .NET app bugs should be reported in [bitwarden/mobile](https://github.com/bitwarden/mobile)"
validations:
required: true
- type: textarea
id: reproduce
attributes:

View File

@@ -1,5 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Legacy Android Bug Reports
url: https://github.com/bitwarden/mobile/issues
about: Bugs found in the publicly available .NET MAUI app should be reported in [bitwarden/mobile](https://github.com/bitwarden/mobile)
- name: Feature Requests
url: https://community.bitwarden.com/c/feature-requests/
about: Request new features using the Community Forums. Please search existing feature requests before making a new one.

View File

@@ -28,6 +28,7 @@ on:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JAVA_VERSION: 17
GITHUB_ACTION_RUN_URL: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
jobs:
build:
@@ -39,7 +40,7 @@ jobs:
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@dbbdc275be76ac10734476cc723d82dfe7ec6eda # v3.4.2
uses: gradle/actions/wrapper-validation@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -61,13 +62,13 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@3783f195e29b74ae398d7caca108814bbafde90e # v1.180.1
uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0
with:
bundler-cache: true
@@ -91,14 +92,14 @@ jobs:
strategy:
fail-fast: false
matrix:
variant: ["prod", "qa"]
variant: ["prod", "dev"]
artifact: ["apk", "aab"]
steps:
- name: Check out repo
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Configure Ruby
uses: ruby/setup-ruby@3783f195e29b74ae398d7caca108814bbafde90e # v1.180.1
uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0
with:
bundler-cache: true
@@ -138,7 +139,7 @@ jobs:
--name google-services.json --file ${{ github.workspace }}/app/src/standardBeta/google-services.json --output none
- name: Download Firebase credentials
if: ${{ matrix.variant == 'prod' && inputs.distribute-to-firebase }}
if: ${{ matrix.variant == 'prod' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
@@ -149,7 +150,7 @@ jobs:
--name app_play_prod_firebase-creds.json --file ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json --output none
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@dbbdc275be76ac10734476cc723d82dfe7ec6eda # v3.4.2
uses: gradle/actions/wrapper-validation@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -171,7 +172,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
@@ -229,14 +230,14 @@ jobs:
keyAlias:bitwarden-beta \
keyPassword:${{ env.PLAY_BETA_KEY_PASSWORD }}
- name: Generate QA Play Store APKs
- name: Generate debug Play Store APKs
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
run: |
bundle exec fastlane assembleDebugApks
- name: Upload release Play Store .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden.aab
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden-standard-release.aab
@@ -244,7 +245,7 @@ jobs:
- name: Upload beta Play Store .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden.beta.aab
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden-standard-beta.aab
@@ -252,7 +253,7 @@ jobs:
- name: Upload release .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden.apk
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden-standard-release.apk
@@ -260,18 +261,18 @@ jobs:
- name: Upload beta .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden.beta.apk
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden-standard-beta.apk
if-no-files-found: error
# When building variants other than 'prod'
- name: Upload other .apk artifact
- name: Upload debug .apk artifact
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden-${{ matrix.variant }}.apk
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden-standard-debug.apk
if-no-files-found: error
@@ -279,90 +280,92 @@ jobs:
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/release/com.x8bit.bitwarden-standard-release.apk" \
> ./bw-android-apk-sha256.txt
> ./com.x8bit.bitwarden.apk-sha256.txt
- name: Create checksum for beta .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/beta/com.x8bit.bitwarden-standard-beta.apk" \
> ./bw-android-beta-apk-sha256.txt
> ./com.x8bit.bitwarden.beta.apk-sha256.txt
- name: Create checksum for release .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
run: |
sha256sum "app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden-standard-release.aab" \
> ./bw-android-aab-sha256.txt
> ./com.x8bit.bitwarden.aab-sha256.txt
- name: Create checksum for beta .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
run: |
sha256sum "app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden-standard-beta.aab" \
> ./bw-android-beta-aab-sha256.txt
> ./com.x8bit.bitwarden.beta.aab-sha256.txt
- name: Create checksum for other .apk artifact
- name: Create checksum for Debug .apk artifact
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/debug/com.x8bit.bitwarden-standard-debug.apk" \
> ./bw-android-${{ matrix.variant }}-apk-sha256.txt
> ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
- name: Upload .apk SHA file for release
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: bw-android-apk-sha256.txt
path: ./bw-android-apk-sha256.txt
name: com.x8bit.bitwarden.apk-sha256.txt
path: ./com.x8bit.bitwarden.apk-sha256.txt
if-no-files-found: error
- name: Upload .apk SHA file for beta
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: bw-android-beta-apk-sha256.txt
path: ./bw-android-beta-apk-sha256.txt
name: com.x8bit.bitwarden.beta.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for release
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: bw-android-aab-sha256.txt
path: ./bw-android-aab-sha256.txt
name: com.x8bit.bitwarden.aab-sha256.txt
path: ./com.x8bit.bitwarden.aab-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for beta
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: bw-android-beta-aab-sha256.txt
path: ./bw-android-beta-aab-sha256.txt
name: com.x8bit.bitwarden.beta.aab-sha256.txt
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
if-no-files-found: error
- name: Upload .apk SHA file for other
- name: Upload .apk SHA file for debug
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: bw-android-${{ matrix.variant }}-apk-sha256.txt
path: ./bw-android-${{ matrix.variant }}-apk-sha256.txt
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
if-no-files-found: error
- name: Install Firebase app distribution plugin
if: ${{ matrix.variant == 'prod' && github.ref_name == 'main' && inputs.distribute-to-firebase }}
if: ${{ matrix.variant == 'prod' && github.ref_name == 'main' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
run: bundle exec fastlane add_plugin firebase_app_distribution
- name: Publish release artifacts to Firebase
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && github.ref_name == 'main' && inputs.distribute-to-firebase }}
if: ${{ matrix.variant == 'prod' && matrix.artifact == 'apk' && github.ref_name == 'main' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
env:
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
run: |
bundle exec fastlane distributeReleasePlayStoreToFirebase \
actionUrl:${{ env.GITHUB_ACTION_RUN_URL }} \
service_credentials_file:${{ env.APP_PLAY_FIREBASE_CREDS_PATH }}
- name: Publish beta artifacts to Firebase
if: ${{ (matrix.variant == 'prod' && matrix.artifact == 'apk') && github.ref_name == 'main' && inputs.distribute-to-firebase }}
if: ${{ (matrix.variant == 'prod' && matrix.artifact == 'apk') && github.ref_name == 'main' && (inputs.distribute-to-firebase || github.event_name == 'push') }}
env:
APP_PLAY_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_play_prod_firebase-creds.json
run: |
bundle exec fastlane distributeBetaPlayStoreToFirebase \
actionUrl:${{ env.GITHUB_ACTION_RUN_URL }} \
service_credentials_file:${{ env.APP_PLAY_FIREBASE_CREDS_PATH }}
- name: Verify Play Store credentials
@@ -384,7 +387,7 @@ jobs:
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Configure Ruby
uses: ruby/setup-ruby@3783f195e29b74ae398d7caca108814bbafde90e # v1.180.1
uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0
with:
bundler-cache: true
@@ -410,7 +413,7 @@ jobs:
--name app_beta_fdroid-keystore.jks --file ${{ github.workspace }}/keystores/app_beta_fdroid-keystore.jks --output none
- name: Download Firebase credentials
if: ${{ inputs.distribute-to-firebase }}
if: ${{ inputs.distribute-to-firebase || github.event_name == 'push' }}
env:
ACCOUNT_NAME: bitwardenci
CONTAINER_NAME: mobile
@@ -421,7 +424,7 @@ jobs:
--name app_fdroid_firebase-creds.json --file ${{ github.workspace }}/secrets/app_fdroid_firebase-creds.json --output none
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@dbbdc275be76ac10734476cc723d82dfe7ec6eda # v3.4.2
uses: gradle/actions/wrapper-validation@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -443,7 +446,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
@@ -466,7 +469,6 @@ jobs:
keyAlias:bitwarden \
keyPassword:"${{ env.FDROID_STORE_PASSWORD }}"
# Generate the F-Droid APK for publishing
- name: Generate F-Droid Beta Artifacts
env:
FDROID_BETA_KEYSTORE_PASSWORD: ${{ secrets.FDROID_BETA_KEYSTORE_PASSWORD }}
@@ -479,7 +481,7 @@ jobs:
keyPassword:"${{ env.FDROID_BETA_KEY_PASSWORD }}"
- name: Upload F-Droid .apk artifact
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden-fdroid.apk
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid-release.apk
@@ -488,13 +490,32 @@ jobs:
- name: Create checksum for F-Droid artifact
run: |
sha256sum "app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid-release.apk" \
> ./bw-fdroid-apk-sha256.txt
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
- name: Upload F-Droid SHA file
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: bw-fdroid-apk-sha256.txt
path: ./bw-fdroid-apk-sha256.txt
name: com.x8bit.bitwarden-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
if-no-files-found: error
- name: Upload F-Droid Beta .apk artifact
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden.beta-fdroid.apk
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden-fdroid-beta.apk
if-no-files-found: error
- name: Create checksum for F-Droid Beta artifact
run: |
sha256sum "app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden-fdroid-beta.apk" \
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
- name: Upload F-Droid Beta SHA file
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
if-no-files-found: error
- name: Install Firebase app distribution plugin
@@ -507,4 +528,5 @@ jobs:
APP_FDROID_FIREBASE_CREDS_PATH: ${{ github.workspace }}/secrets/app_fdroid_firebase-creds.json
run: |
bundle exec fastlane distributeReleaseFDroidToFirebase \
actionUrl:${{ env.GITHUB_ACTION_RUN_URL }} \
service_credentials_file:${{ env.APP_FDROID_FIREBASE_CREDS_PATH }}

View File

@@ -10,7 +10,7 @@ on:
jobs:
crowdin-sync:
name: Autosync
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
env:
_CROWDIN_PROJECT_ID: "269690"
steps:
@@ -30,7 +30,7 @@ jobs:
secrets: "crowdin-api-token, github-gpg-private-key, github-gpg-private-key-passphrase"
- name: Download translations
uses: crowdin/github-action@30849777a3cba6ee9a09e24e195272b8287a0a5b # v1.20.4
uses: crowdin/github-action@6ed209d411599a981ccb978df3be9dc9b8a81699 # v2.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}

View File

@@ -29,7 +29,7 @@ jobs:
secrets: "crowdin-api-token"
- name: Upload sources
uses: crowdin/github-action@30849777a3cba6ee9a09e24e195272b8287a0a5b # v1.20.4
uses: crowdin/github-action@6ed209d411599a981ccb978df3be9dc9b8a81699 # v2.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }}

View File

@@ -31,7 +31,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with Checkmarx
uses: checkmarx/ast-github-action@92b6d52097badece63efe997ffe75207010bb80c # 2.0.29
uses: checkmarx/ast-github-action@1fe318de2993222574e6249750ba9000a4e2a6cd # 2.0.33
env:
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
with:
@@ -46,7 +46,7 @@ jobs:
--output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@23acc5c183826b7a8a97bce3cecc52db901f8251 # v3.25.10
uses: github/codeql-action/upload-sarif@883d8588e56d1753a8a58c1c86e88976f0c23449 # v3.26.3
with:
sarif_file: cx_result.sarif
@@ -66,7 +66,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with SonarCloud
uses: sonarsource/sonarcloud-github-action@e44258b109568baa0df60ed515909fc6c72cba92 # v2.3.0
uses: sonarsource/sonarcloud-github-action@eb211723266fe8e83102bac7361f0a05c3ac1d1b # v3.0.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -36,7 +36,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@dbbdc275be76ac10734476cc723d82dfe7ec6eda # v3.4.2
uses: gradle/actions/wrapper-validation@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -58,12 +58,12 @@ jobs:
${{ runner.os }}-build-
- name: Configure Ruby
uses: ruby/setup-ruby@3783f195e29b74ae398d7caca108814bbafde90e # v1.180.1
uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0
with:
bundler-cache: true
- name: Configure JDK
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}

View File

@@ -1 +0,0 @@
shroud.reportKover 'App', 'app/build/reports/kover/reportStandardDebug.xml', 80, 80, false

View File

@@ -10,20 +10,20 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.947.0)
aws-sdk-core (3.199.0)
aws-partitions (1.966.0)
aws-sdk-core (3.201.5)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.87.0)
aws-sdk-core (~> 3, >= 3.199.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.154.0)
aws-sdk-core (~> 3, >= 3.199.0)
aws-sdk-kms (1.88.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.158.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
aws-sigv4 (1.8.0)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.9.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.2.0)
@@ -39,7 +39,7 @@ GEM
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.110.0)
excon (0.111.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
@@ -61,7 +61,7 @@ GEM
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
@@ -69,7 +69,7 @@ GEM
faraday_middleware (1.2.0)
faraday (~> 1.0)
fastimage (2.3.1)
fastlane (2.221.1)
fastlane (2.222.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
@@ -134,7 +134,7 @@ GEM
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.7.0)
google-cloud-core (1.7.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
@@ -155,14 +155,14 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.6)
http-cookie (1.0.7)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
json (2.7.2)
jwt (2.8.2)
base64
mini_magick (4.13.1)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.15.0)
multipart-post (2.4.1)
@@ -172,14 +172,14 @@ GEM
optparse (0.5.0)
os (1.1.4)
plist (3.7.1)
public_suffix (6.0.0)
public_suffix (6.0.1)
rake (13.2.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.9)
rexml (3.3.5)
strscan
rouge (2.0.7)
ruby2_keywords (0.0.5)
@@ -207,13 +207,13 @@ GEM
uber (0.1.0)
unicode-display_width (2.5.0)
word_wrap (1.0.0)
xcodeproj (1.24.0)
xcodeproj (1.25.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
rexml (>= 3.3.2, < 4.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)

View File

@@ -1,4 +1,7 @@
# Bitwarden Android
# Bitwarden Android (BETA)
> [!TIP]
> This repo has the new native Android app, currently in [Beta](https://community.bitwarden.com/t/about-the-beta-program/39185). Looking for the legacy .NET MAUI apps? Head on over to [bitwarden/mobile](https://github.com/bitwarden/mobile)
## Contents
@@ -8,7 +11,7 @@
## Compatibility
- **Minimum SDK**: 28
- **Minimum SDK**: 29
- **Target SDK**: 34
- **Device Types Supported**: Phone and Tablet
- **Orientations Supported**: Portrait and Landscape

View File

@@ -61,12 +61,21 @@ android {
signingConfig = signingConfigs.getByName("debug")
isDebuggable = true
isMinifyEnabled = false
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "true")
}
// Beta and Release variants are identical except beta has a different package name
create("beta") {
initWith(buildTypes.getByName("release"))
applicationIdSuffix = ".beta"
isDebuggable = false
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false")
}
release {
isDebuggable = false
@@ -75,6 +84,8 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false")
}
}
@@ -253,7 +264,7 @@ kover {
tasks {
getByName("check") {
// Add detekt with type resolution to check
dependsOn("detektMain")
dependsOn("detekt")
}
withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach {
@@ -289,6 +300,7 @@ sonar {
property("sonar.sources", "app/src/")
property("sonar.tests", "app/src/")
property("sonar.test.inclusions", "app/src/test/")
property("sonar.exclusions", "app/src/test/")
}
}

View File

@@ -3,15 +3,6 @@
xmlns:tools="http://schemas.android.com/tools">
<application tools:ignore="MissingApplicationIcon">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<!-- Disable Crashlytics for debug builds -->
<meta-data
android:name="firebase_crashlytics_collection_enabled"

View File

@@ -11,4 +11,6 @@ class CrashLogsManagerImpl(
legacyAppCenterMigrator: LegacyAppCenterMigrator,
) : CrashLogsManager {
override var isEnabled: Boolean = true
override fun trackNonFatalException(e: Exception) = Unit
}

View File

@@ -55,6 +55,26 @@
<data android:mimeType="video/*" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="vault.bitwarden.com" />
<data android:host="vault.bitwarden.eu" />
<data android:host="*.bitwarden.pw" />
<data android:pathPattern="/redirect-connector.*" />
</intent-filter>
<intent-filter>
<action android:name="com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY" />
<action android:name="com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY" />
<action android:name="com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
@@ -141,6 +161,27 @@
</intent-filter>
</service>
<!--
The CredentialProviderService name below refers to the legacy Xamarin app's service name.
This must always match in order for the app to properly query if it is providing credential
services.
-->
<!--suppress AndroidDomInspection -->
<service
android:name="com.x8bit.bitwarden.Autofill.CredentialProviderService"
android:enabled="true"
android:exported="true"
android:label="@string/bitwarden"
android:permission="android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="android.service.credentials.CredentialProviderService" />
</intent-filter>
<meta-data
android:name="android.credentials.provider"
android:resource="@xml/provider" />
</service>
<!-- This is required to support in-app language picker in Android 12 (API 32) and below -->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
@@ -151,6 +192,42 @@
android:value="true" />
</service>
<!--
The GeneratorTileService name below refers to the legacy Xamarin app's service name.
This must always match in order for the app to properly query if it is providing generator
tile services.
-->
<!--suppress AndroidDomInspection -->
<service
android:name="com.x8bit.bitwarden.GeneratorTileService"
android:exported="true"
android:icon="@drawable/ic_generator"
android:label="@string/password_generator"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<!--
The MyVaultTileService name below refers to the legacy Xamarin app's service name.
This must always match in order for the app to properly query if it is providing vault
tile services.
-->
<!--suppress AndroidDomInspection -->
<service
android:name="com.x8bit.bitwarden.MyVaultTileService"
android:exported="true"
android:icon="@drawable/ic_notification"
android:label="@string/my_vault"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />

View File

@@ -7,10 +7,14 @@ import androidx.core.app.AppComponentFactory
import com.x8bit.bitwarden.data.autofill.BitwardenAutofillService
import com.x8bit.bitwarden.data.autofill.fido2.BitwardenFido2ProviderService
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.tiles.BitwardenGeneratorTileService
import com.x8bit.bitwarden.data.tiles.BitwardenVaultTileService
private const val LEGACY_AUTOFILL_SERVICE_NAME = "com.x8bit.bitwarden.Autofill.AutofillService"
private const val LEGACY_CREDENTIAL_SERVICE_NAME =
"com.x8bit.bitwarden.Autofill.CredentialProviderService"
private const val LEGACY_VAULT_TILE_SERVICE_NAME = "com.x8bit.bitwarden.MyVaultTileService"
private const val LEGACY_GENERATOR_TILE_SERVICE_NAME = "com.x8bit.bitwarden.GeneratorTileService"
/**
* A factory class that allows us to intercept when a manifest element is being instantiated
@@ -20,10 +24,11 @@ private const val LEGACY_CREDENTIAL_SERVICE_NAME =
@OmitFromCoverage
class BitwardenAppComponentFactory : AppComponentFactory() {
/**
* Used to intercept when the [BitwardenAutofillService] or [BitwardenFido2ProviderService] is
* being instantiated and modify which service is created. This is required because the
* [className] used in the manifest must match the legacy Xamarin app service name but the
* service name in this app is different.
* Used to intercept when the [BitwardenAutofillService], [BitwardenFido2ProviderService],
* [BitwardenVaultTileService], or [BitwardenGeneratorTileService] is being instantiated and
* modify which service is created. This is required because the [className] used in the
* manifest must match the legacy Xamarin app service name but the service name in this app is
* different.
*/
override fun instantiateServiceCompat(
cl: ClassLoader,
@@ -48,6 +53,18 @@ class BitwardenAppComponentFactory : AppComponentFactory() {
}
}
LEGACY_VAULT_TILE_SERVICE_NAME -> {
super.instantiateServiceCompat(cl, BitwardenVaultTileService::class.java.name, intent)
}
LEGACY_GENERATOR_TILE_SERVICE_NAME -> {
super.instantiateServiceCompat(
cl,
BitwardenGeneratorTileService::class.java.name,
intent,
)
}
else -> super.instantiateServiceCompat(cl, className, intent)
}
}

View File

@@ -2,6 +2,8 @@ package com.x8bit.bitwarden
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.viewModels
@@ -11,17 +13,18 @@ import androidx.compose.runtime.getValue
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.x8bit.bitwarden.data.autofill.manager.AutofillActivityManager
import com.x8bit.bitwarden.data.autofill.manager.AutofillCompletionManager
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavScreen
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
/**
@@ -42,13 +45,14 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var debugLaunchManager: DebugMenuLaunchManager
override fun onCreate(savedInstanceState: Bundle?) {
var shouldShowSplashScreen = true
installSplashScreen().setKeepOnScreenCondition { shouldShowSplashScreen }
super.onCreate(savedInstanceState)
observeViewModelEvents()
if (savedInstanceState == null) {
mainViewModel.trySendAction(
MainAction.ReceiveFirstIntent(
@@ -66,11 +70,20 @@ class MainActivity : AppCompatActivity() {
}
setContent {
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
val navController = rememberNavController()
EventsEffect(viewModel = mainViewModel) { event ->
when (event) {
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
MainEvent.Recreate -> handleRecreate()
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
}
}
updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed)
LocalManagerProvider {
BitwardenTheme(theme = state.theme) {
RootNavScreen(
onSplashScreenRemoved = { shouldShowSplashScreen = false },
navController = navController,
)
}
}
@@ -93,16 +106,18 @@ class MainActivity : AppCompatActivity() {
currentFocus?.clearFocus()
}
private fun observeViewModelEvents() {
mainViewModel
.eventFlow
.onEach { event ->
when (event) {
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
MainEvent.Recreate -> handleRecreate()
}
}
.launchIn(lifecycleScope)
override fun dispatchTouchEvent(event: MotionEvent): Boolean = debugLaunchManager
.actionOnInputEvent(event = event, action = ::sendOpenDebugMenuEvent)
.takeIf { it }
?: super.dispatchTouchEvent(event)
override fun dispatchKeyEvent(event: KeyEvent): Boolean = debugLaunchManager
.actionOnInputEvent(event = event, action = ::sendOpenDebugMenuEvent)
.takeIf { it }
?: super.dispatchKeyEvent(event)
private fun sendOpenDebugMenuEvent() {
mainViewModel.trySendAction(MainAction.OpenDebugMenu)
}
private fun handleCompleteAutofill(event: MainEvent.CompleteAutofill) {

View File

@@ -6,8 +6,12 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.util.getCompleteRegistrationDataIntentOrNull
import com.x8bit.bitwarden.data.auth.util.getPasswordlessRequestDataIntentOrNull
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2AssertionRequestOrNull
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2CredentialRequestOrNull
import com.x8bit.bitwarden.data.autofill.fido2.util.getFido2GetCredentialsRequestOrNull
import com.x8bit.bitwarden.data.autofill.manager.AutofillSelectionManager
import com.x8bit.bitwarden.data.autofill.util.getAutofillSaveItemOrNull
import com.x8bit.bitwarden.data.autofill.util.getAutofillSelectionDataOrNull
@@ -31,6 +35,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import java.time.Clock
import javax.inject.Inject
private const val SPECIAL_CIRCUMSTANCE_KEY = "special-circumstance"
@@ -44,11 +49,13 @@ class MainViewModel @Inject constructor(
autofillSelectionManager: AutofillSelectionManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
private val fido2CredentialManager: Fido2CredentialManager,
private val intentManager: IntentManager,
settingsRepository: SettingsRepository,
vaultRepository: VaultRepository,
private val vaultRepository: VaultRepository,
private val authRepository: AuthRepository,
private val savedStateHandle: SavedStateHandle,
private val clock: Clock,
) : BaseViewModel<MainState, MainEvent, MainAction>(
initialState = MainState(
theme = settingsRepository.appTheme,
@@ -108,6 +115,10 @@ class MainViewModel @Inject constructor(
.onEach {
when (it) {
is VaultStateEvent.Locked -> {
// Similar to account switching, triggering this action too soon can
// interfere with animations or navigation logic, so we will delay slightly.
@Suppress("MagicNumber")
delay(500)
trySendAction(MainAction.Internal.VaultUnlockStateChange)
}
@@ -129,9 +140,14 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.VaultUnlockStateChange -> handleVaultUnlockStateChange()
is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action)
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
MainAction.OpenDebugMenu -> handleOpenDebugMenu()
}
}
private fun handleOpenDebugMenu() {
sendEvent(MainEvent.NavigateToDebugMenu)
}
private fun handleAutofillSelectionReceive(
action: MainAction.Internal.AutofillSelectionReceive,
) {
@@ -168,6 +184,7 @@ class MainViewModel @Inject constructor(
)
}
@Suppress("LongMethod")
private fun handleIntent(
intent: Intent,
isFirstIntent: Boolean,
@@ -179,6 +196,9 @@ class MainViewModel @Inject constructor(
val hasGeneratorShortcut = intent.isPasswordGeneratorShortcut
val hasVaultShortcut = intent.isMyVaultShortcut
val fido2CredentialRequestData = intent.getFido2CredentialRequestOrNull()
val completeRegistrationData = intent.getCompleteRegistrationDataIntentOrNull()
val fido2CredentialAssertionRequest = intent.getFido2AssertionRequestOrNull()
val fido2GetCredentialsRequest = intent.getFido2GetCredentialsRequestOrNull()
when {
passwordlessRequestData != null -> {
specialCircumstanceManager.specialCircumstance =
@@ -190,6 +210,17 @@ class MainViewModel @Inject constructor(
)
}
completeRegistrationData != null -> {
if (authRepository.activeUserId != null) {
authRepository.hasPendingAccountAddition = true
}
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.PreLogin.CompleteRegistration(
completeRegistrationData = completeRegistrationData,
timestamp = clock.millis(),
)
}
autofillSaveItem != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.AutofillSave(
@@ -218,6 +249,10 @@ class MainViewModel @Inject constructor(
}
fido2CredentialRequestData != null -> {
// Set the user's verification status when a new FIDO 2 request is received to force
// explicit verification if the user's vault is unlocked when the request is
// received.
fido2CredentialManager.isUserVerified = false
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2Save(
fido2CredentialRequest = fido2CredentialRequestData,
@@ -231,6 +266,20 @@ class MainViewModel @Inject constructor(
}
}
fido2CredentialAssertionRequest != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2Assertion(
fido2AssertionRequest = fido2CredentialAssertionRequest,
)
}
fido2GetCredentialsRequest != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.Fido2GetCredentials(
fido2GetCredentialsRequest = fido2GetCredentialsRequest,
)
}
hasGeneratorShortcut -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.GeneratorShortcut
@@ -271,6 +320,11 @@ sealed class MainAction {
*/
data class ReceiveNewIntent(val intent: Intent) : MainAction()
/**
* Receive event to open the debug menu.
*/
data object OpenDebugMenu : MainAction()
/**
* Actions for internal use by the ViewModel.
*/
@@ -322,4 +376,9 @@ sealed class MainEvent {
* Event indicating that the UI should recreate itself.
*/
data object Recreate : MainEvent()
/**
* Navigate to the debug menu.
*/
data object NavigateToDebugMenu : MainEvent()
}

View File

@@ -45,12 +45,37 @@ interface AuthDiskSource {
*/
fun clearData(userId: String)
/**
* Retrieves the state indicating that the user should use a key connector.
*/
fun getShouldUseKeyConnector(userId: String): Boolean?
/**
* Retrieves the state indicating that the user should use a key connector as a flow.
*/
fun getShouldUseKeyConnectorFlow(userId: String): Flow<Boolean?>
/**
* Stores the boolean indicating that the user should use a key connector.
*/
fun storeShouldUseKeyConnector(userId: String, shouldUseKeyConnector: Boolean?)
/**
* Retrieves the state indicating that the user has completed login with TDE.
*/
fun getIsTdeLoginComplete(userId: String): Boolean?
/**
* Stores the boolean indicating that the user has completed login with TDE.
*/
fun storeIsTdeLoginComplete(userId: String, isTdeLoginComplete: Boolean?)
/**
* Retrieves the state indicating that the user has chosen to trust this device.
*
* Note: This indicates intent to trust the device, the device may not be trusted yet.
*/
fun getShouldTrustDevice(userId: String): Boolean
fun getShouldTrustDevice(userId: String): Boolean?
/**
* Stores the boolean indicating that the user has chosen to trust this device for the given
@@ -60,25 +85,6 @@ interface AuthDiskSource {
*/
fun storeShouldTrustDevice(userId: String, shouldTrustDevice: Boolean?)
/**
* Retrieves the "last active time" for the given [userId], in milliseconds.
*
* This time is intended to be derived from a call to
* [SystemClock.elapsedRealtime()](https://developer.android.com/reference/android/os/SystemClock#elapsedRealtime())
*/
fun getLastActiveTimeMillis(userId: String): Long?
/**
* Stores the [lastActiveTimeMillis] for the given [userId].
*
* This time is intended to be derived from a call to
* [SystemClock.elapsedRealtime()](https://developer.android.com/reference/android/os/SystemClock#elapsedRealtime())
*/
fun storeLastActiveTimeMillis(
userId: String,
lastActiveTimeMillis: Long?,
)
/**
* Retrieves the number of consecutive invalid lock attempts for the given [userId].
*/

View File

@@ -28,7 +28,6 @@ private const val UNIQUE_APP_ID_KEY = "appId"
private const val REMEMBERED_EMAIL_ADDRESS_KEY = "rememberedEmail"
private const val REMEMBERED_ORG_IDENTIFIER_KEY = "rememberedOrgIdentifier"
private const val STATE_KEY = "state"
private const val LAST_ACTIVE_TIME_KEY = "lastActiveTime"
private const val INVALID_UNLOCK_ATTEMPTS_KEY = "invalidUnlockAttempts"
private const val MASTER_KEY_ENCRYPTION_USER_KEY = "masterKeyEncryptedUserKey"
private const val MASTER_KEY_ENCRYPTION_PRIVATE_KEY = "encPrivateKey"
@@ -40,6 +39,8 @@ private const val TWO_FACTOR_TOKEN_KEY = "twoFactorToken"
private const val MASTER_PASSWORD_HASH_KEY = "keyHash"
private const val POLICIES_KEY = "policies"
private const val SHOULD_TRUST_DEVICE_KEY = "shouldTrustDevice"
private const val TDE_LOGIN_COMPLETE = "tdeLoginComplete"
private const val USES_KEY_CONNECTOR = "usesKeyConnector"
/**
* Primary implementation of [AuthDiskSource].
@@ -57,6 +58,8 @@ class AuthDiskSourceImpl(
AuthDiskSource {
private val inMemoryPinProtectedUserKeys = mutableMapOf<String, String?>()
private val mutableShouldUseKeyConnectorFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableOrganizationsFlowMap =
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?>>()
private val mutablePoliciesFlowMap =
@@ -111,7 +114,6 @@ class AuthDiskSourceImpl(
.onSubscription { emit(userState) }
override fun clearData(userId: String) {
storeLastActiveTimeMillis(userId = userId, lastActiveTimeMillis = null)
storeInvalidUnlockAttempts(userId = userId, invalidUnlockAttempts = null)
storeUserKey(userId = userId, userKey = null)
storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null)
@@ -124,33 +126,45 @@ class AuthDiskSourceImpl(
storeMasterPasswordHash(userId = userId, passwordHash = null)
storePolicies(userId = userId, policies = null)
storeAccountTokens(userId = userId, accountTokens = null)
storeShouldUseKeyConnector(userId = userId, shouldUseKeyConnector = null)
storeIsTdeLoginComplete(userId = userId, isTdeLoginComplete = null)
// Do not remove the DeviceKey or PendingAuthRequest on logout, these are persisted
// indefinitely unless the TDE flow explicitly removes them.
}
override fun getShouldTrustDevice(userId: String): Boolean =
requireNotNull(
getBoolean(key = SHOULD_TRUST_DEVICE_KEY.appendIdentifier(userId), default = false),
override fun getShouldUseKeyConnectorFlow(userId: String): Flow<Boolean?> =
getMutableShouldUseKeyConnectorFlowMap(userId = userId)
.onSubscription { emit(getShouldUseKeyConnector(userId = userId)) }
override fun getShouldUseKeyConnector(
userId: String,
): Boolean? = getBoolean(key = USES_KEY_CONNECTOR.appendIdentifier(userId))
override fun storeShouldUseKeyConnector(userId: String, shouldUseKeyConnector: Boolean?) {
putBoolean(
key = USES_KEY_CONNECTOR.appendIdentifier(userId),
value = shouldUseKeyConnector,
)
getMutableShouldUseKeyConnectorFlowMap(userId = userId).tryEmit(shouldUseKeyConnector)
}
override fun getIsTdeLoginComplete(
userId: String,
): Boolean? = getBoolean(key = TDE_LOGIN_COMPLETE.appendIdentifier(userId))
override fun storeIsTdeLoginComplete(userId: String, isTdeLoginComplete: Boolean?) {
putBoolean(TDE_LOGIN_COMPLETE.appendIdentifier(userId), isTdeLoginComplete)
}
override fun getShouldTrustDevice(
userId: String,
): Boolean? = getBoolean(key = SHOULD_TRUST_DEVICE_KEY.appendIdentifier(userId))
override fun storeShouldTrustDevice(userId: String, shouldTrustDevice: Boolean?) {
putBoolean(SHOULD_TRUST_DEVICE_KEY.appendIdentifier(userId), shouldTrustDevice)
}
override fun getLastActiveTimeMillis(userId: String): Long? =
getLong(key = LAST_ACTIVE_TIME_KEY.appendIdentifier(userId))
override fun storeLastActiveTimeMillis(
userId: String,
lastActiveTimeMillis: Long?,
) {
putLong(
key = LAST_ACTIVE_TIME_KEY.appendIdentifier(userId),
value = lastActiveTimeMillis,
)
}
override fun getInvalidUnlockAttempts(userId: String): Int? =
getInt(key = INVALID_UNLOCK_ATTEMPTS_KEY.appendIdentifier(userId))
@@ -384,6 +398,13 @@ class AuthDiskSourceImpl(
putString(key = UNIQUE_APP_ID_KEY, value = it)
}
private fun getMutableShouldUseKeyConnectorFlowMap(
userId: String,
): MutableSharedFlow<Boolean?> =
mutableShouldUseKeyConnectorFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableOrganizationsFlow(
userId: String,
): MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?> =

View File

@@ -13,6 +13,13 @@ import retrofit2.http.POST
* Defines raw calls under the /accounts API with authentication applied.
*/
interface AuthenticatedAccountsApi {
/**
* Converts the currently active account to a key-connector account.
*/
@POST("/accounts/convert-to-key-connector")
suspend fun convertToKeyConnector(): Result<Unit>
/**
* Creates the keys for the current account.
*/

View File

@@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyRequestJson
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Url
/**
* Defines raw calls specific for key connectors that use custom urls.
*/
@Keep
interface AuthenticatedKeyConnectorApi {
@POST
suspend fun storeMasterKeyToKeyConnector(
@Url url: String,
@Body body: KeyConnectorMasterKeyRequestJson,
): Result<Unit>
}

View File

@@ -1,14 +1,17 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorKeyRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_KEY_AUTHORIZATION
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.POST
/**
* Defines raw calls under the /accounts API.
*/
interface AccountsApi {
interface UnauthenticatedAccountsApi {
@POST("/accounts/password-hint")
suspend fun passwordHintRequest(
@Body body: PasswordHintRequestJson,
@@ -18,4 +21,10 @@ interface AccountsApi {
suspend fun resendVerificationCodeEmail(
@Body body: ResendEmailRequestJson,
): Result<Unit>
@POST("/accounts/set-key-connector-key")
suspend fun setKeyConnectorKey(
@Body body: KeyConnectorKeyRequestJson,
@Header(HEADER_KEY_AUTHORIZATION) bearerToken: String,
): Result<Unit>
}

View File

@@ -5,8 +5,11 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJso
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import kotlinx.serialization.json.JsonPrimitive
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.Field
@@ -19,7 +22,7 @@ import retrofit2.http.Query
/**
* Defines raw calls under the /identity API.
*/
interface IdentityApi {
interface UnauthenticatedIdentityApi {
@POST("/connect/token")
@Suppress("LongParameterList")
@@ -66,4 +69,14 @@ interface IdentityApi {
@POST("/accounts/register")
suspend fun register(@Body body: RegisterRequestJson): Result<RegisterResponseJson.Success>
@POST("/accounts/register/finish")
suspend fun registerFinish(
@Body body: RegisterFinishRequestJson,
): Result<RegisterResponseJson.Success>
@POST("/accounts/register/send-verification-email")
suspend fun sendVerificationEmail(
@Body body: SendVerificationEmailRequestJson,
): Result<JsonPrimitive?>
}

View File

@@ -0,0 +1,30 @@
package com.x8bit.bitwarden.data.auth.datasource.network.api
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyResponseJson
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_KEY_AUTHORIZATION
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Url
/**
* Defines raw calls specific for key connectors that use custom urls.
*/
@Keep
interface UnauthenticatedKeyConnectorApi {
@POST
suspend fun storeMasterKeyToKeyConnector(
@Url url: String,
@Header(HEADER_KEY_AUTHORIZATION) bearerToken: String,
@Body body: KeyConnectorMasterKeyRequestJson,
): Result<Unit>
@GET
suspend fun getMasterKeyFromKeyConnector(
@Url url: String,
@Header(HEADER_KEY_AUTHORIZATION) bearerToken: String,
): Result<KeyConnectorMasterKeyResponseJson>
}

View File

@@ -8,7 +8,7 @@ import retrofit2.http.POST
/**
* Defines raw calls under the /organizations API.
*/
interface OrganizationApi {
interface UnauthenticatedOrganizationApi {
/**
* Checks for the claimed domain organization of an email for SSO purposes.
*/

View File

@@ -36,8 +36,12 @@ object AuthNetworkModule {
retrofits: Retrofits,
json: Json,
): AccountsService = AccountsServiceImpl(
accountsApi = retrofits.unauthenticatedApiRetrofit.create(),
unauthenticatedAccountsApi = retrofits.unauthenticatedApiRetrofit.create(),
authenticatedAccountsApi = retrofits.authenticatedApiRetrofit.create(),
unauthenticatedKeyConnectorApi = retrofits.createStaticRetrofit().create(),
authenticatedKeyConnectorApi = retrofits
.createStaticRetrofit(isAuthenticated = true)
.create(),
json = json,
)
@@ -64,7 +68,7 @@ object AuthNetworkModule {
retrofits: Retrofits,
json: Json,
): IdentityService = IdentityServiceImpl(
api = retrofits.unauthenticatedIdentityRetrofit.create(),
unauthenticatedIdentityApi = retrofits.unauthenticatedIdentityRetrofit.create(),
json = json,
)
@@ -73,10 +77,8 @@ object AuthNetworkModule {
fun providesHaveIBeenPwnedService(
retrofits: Retrofits,
): HaveIBeenPwnedService = HaveIBeenPwnedServiceImpl(
retrofits
.staticRetrofitBuilder
.baseUrl("https://api.pwnedpasswords.com")
.build()
api = retrofits
.createStaticRetrofit(baseUrl = "https://api.pwnedpasswords.com")
.create(),
)
@@ -95,6 +97,6 @@ object AuthNetworkModule {
retrofits: Retrofits,
): OrganizationService = OrganizationServiceImpl(
authenticatedOrganizationApi = retrofits.authenticatedApiRetrofit.create(),
organizationApi = retrofits.unauthenticatedApiRetrofit.create(),
unauthenticatedOrganizationApi = retrofits.unauthenticatedApiRetrofit.create(),
)
}

View File

@@ -28,6 +28,7 @@ sealed class GetTokenResponseJson {
* this token will be cached and used for future auth requests.
* @property masterPasswordPolicyOptions The options available for a user's master password.
* @property userDecryptionOptions The options available to a user for decryption.
* @property keyConnectorUrl URL to the user's key connector.
*/
@Serializable
data class Success(
@@ -75,6 +76,9 @@ sealed class GetTokenResponseJson {
@SerialName("UserDecryptionOptions")
val userDecryptionOptions: UserDecryptionOptionsJson?,
@SerialName("KeyConnectorUrl")
val keyConnectorUrl: String?,
) : GetTokenResponseJson()
/**

View File

@@ -0,0 +1,33 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the request body used to create the key connector keys for an account.
*/
@Serializable
data class KeyConnectorKeyRequestJson(
@SerialName("key") val userKey: String,
@SerialName("keys") val keys: Keys,
@SerialName("kdf") val kdfType: KdfTypeJson,
@SerialName("kdfIterations") val kdfIterations: Int?,
@SerialName("kdfMemory") val kdfMemory: Int?,
@SerialName("kdfParallelism") val kdfParallelism: Int?,
@SerialName("orgIdentifier") val organizationIdentifier: String,
) {
/**
* A keys object containing public and private keys.
*
* @param publicKey the public key (encrypted).
* @param encryptedPrivateKey the private key (encrypted).
*/
@Serializable
data class Keys(
@SerialName("publicKey")
val publicKey: String,
@SerialName("encryptedPrivateKey")
val encryptedPrivateKey: String,
)
}

View File

@@ -0,0 +1,12 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the request body used to store the master key in the cloud.
*/
@Serializable
data class KeyConnectorMasterKeyRequestJson(
@SerialName("Key") val masterKey: String,
)

View File

@@ -0,0 +1,12 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the response body used to retrieve the master key from the cloud.
*/
@Serializable
data class KeyConnectorMasterKeyResponseJson(
@SerialName("key") val masterKey: String,
)

View File

@@ -0,0 +1,64 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequestJson.Keys
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for register.
*
* @param email the email to be registered.
* @param emailVerificationToken token used to finish the registration process.
* @param masterPasswordHash the master password (encrypted).
* @param masterPasswordHint the hint for the master password (nullable).
* @param captchaResponse the captcha bypass token.
* @param userSymmetricKey the user key for the request (encrypted).
* @param userAsymmetricKeys a [Keys] object containing public and private keys.
* @param kdfType the kdf type represented as an [Int].
* @param kdfIterations the number of kdf iterations.
*/
@Serializable
data class RegisterFinishRequestJson(
@SerialName("email")
val email: String,
@SerialName("emailVerificationToken")
val emailVerificationToken: String,
@SerialName("masterPasswordHash")
val masterPasswordHash: String,
@SerialName("masterPasswordHint")
val masterPasswordHint: String?,
@SerialName("captchaResponse")
val captchaResponse: String?,
@SerialName("userSymmetricKey")
val userSymmetricKey: String,
@SerialName("userAsymmetricKeys")
val userAsymmetricKeys: Keys,
@SerialName("kdf")
val kdfType: KdfTypeJson,
@SerialName("kdfIterations")
val kdfIterations: UInt,
) {
/**
* A keys object containing public and private keys.
*
* @param publicKey the public key (encrypted).
* @param encryptedPrivateKey the private key (encrypted).
*/
@Serializable
data class Keys(
@SerialName("publicKey")
val publicKey: String,
@SerialName("encryptedPrivateKey")
val encryptedPrivateKey: String,
)
}

View File

@@ -46,7 +46,6 @@ sealed class RegisterResponseJson {
/**
* Represents the json body of an invalid register request.
*
* @param message
* @param validationErrors a map where each value is a list of error messages for each key.
* The values in the array should be used for display to the user, since the keys tend to come
* back as nonsense. (eg: empty string key)
@@ -54,18 +53,17 @@ sealed class RegisterResponseJson {
@Serializable
data class Invalid(
@SerialName("message")
val message: String?,
private val invalidMessage: String? = null,
@SerialName("Message")
private val errorMessage: String? = null,
@SerialName("validationErrors")
val validationErrors: Map<String, List<String>>?,
) : RegisterResponseJson()
/**
* A different register error with a message.
*/
@Serializable
data class Error(
@SerialName("Message")
val message: String?,
) : RegisterResponseJson()
) : RegisterResponseJson() {
/**
* A generic error message.
*/
val message: String? get() = invalidMessage ?: errorMessage
}
}

View File

@@ -0,0 +1,23 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for send verification email.
*
* @param email the email to be registered.
* @param name the name to be registered.
* @param receiveMarketingEmails the answer to receive marketing emails.
*/
@Serializable
data class SendVerificationEmailRequestJson(
@SerialName("email")
val email: String,
@SerialName("name")
val name: String?,
@SerialName("receiveMarketingEmails")
val receiveMarketingEmails: Boolean,
)

View File

@@ -0,0 +1,45 @@
package com.x8bit.bitwarden.data.auth.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* The response body for sending a verification email.
*/
@Serializable
sealed class SendVerificationEmailResponseJson {
/**
* Models a successful json response.
*
* @param emailVerificationToken the token to verify the email.
*/
@Serializable
data class Success(
val emailVerificationToken: String?,
) : SendVerificationEmailResponseJson()
/**
* Represents the json body of an invalid request.
*
* @param validationErrors a map where each value is a list of error messages for each key.
* The values in the array should be used for display to the user, since the keys tend to come
* back as nonsense. (eg: empty string key)
*/
@Serializable
data class Invalid(
@SerialName("message")
private val invalidMessage: String? = null,
@SerialName("Message")
private val errorMessage: String? = null,
@SerialName("validationErrors")
val validationErrors: Map<String, List<String>>?,
) : SendVerificationEmailResponseJson() {
/**
* A generic error message.
*/
val message: String? get() = invalidMessage ?: errorMessage
}
}

View File

@@ -1,6 +1,8 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorKeyRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
@@ -9,8 +11,14 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequest
/**
* Provides an API for querying accounts endpoints.
*/
@Suppress("TooManyFunctions")
interface AccountsService {
/**
* Converts the currently active account to a key-connector account.
*/
suspend fun convertToKeyConnector(): Result<Unit>
/**
* Creates a new account's keys.
*/
@@ -49,8 +57,50 @@ interface AccountsService {
*/
suspend fun resetPassword(body: ResetPasswordRequestJson): Result<Unit>
/**
* Set the key connector key.
*
* This API requires the [accessToken] to be passed in manually because it occurs during the
* login process.
*/
suspend fun setKeyConnectorKey(
accessToken: String,
body: KeyConnectorKeyRequestJson,
): Result<Unit>
/**
* Set the password.
*/
suspend fun setPassword(body: SetPasswordRequestJson): Result<Unit>
/**
* Retrieves the master key from the key connector.
*
* This API requires the [accessToken] to be passed in manually because it occurs during the
* login process.
*/
suspend fun getMasterKeyFromKeyConnector(
url: String,
accessToken: String,
): Result<KeyConnectorMasterKeyResponseJson>
/**
* Stores the master key to the key connector.
*/
suspend fun storeMasterKeyToKeyConnector(
url: String,
masterKey: String,
): Result<Unit>
/**
* Stores the master key to the key connector.
*
* This API requires the [accessToken] to be passed in manually because it occurs during the
* login process.
*/
suspend fun storeMasterKeyToKeyConnector(
url: String,
accessToken: String,
masterKey: String,
): Result<Unit>
}

View File

@@ -1,10 +1,15 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.AccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedAccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedKeyConnectorApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.UnauthenticatedAccountsApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.UnauthenticatedKeyConnectorApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.CreateAccountKeysRequest
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.DeleteAccountResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorKeyRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
@@ -12,15 +17,28 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordReque
import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyOtpRequestJson
import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError
import com.x8bit.bitwarden.data.platform.datasource.network.util.HEADER_VALUE_BEARER_PREFIX
import com.x8bit.bitwarden.data.platform.datasource.network.util.parseErrorBodyOrNull
import kotlinx.serialization.json.Json
/**
* The default implementation of the [AccountsService].
*/
@Suppress("TooManyFunctions")
class AccountsServiceImpl(
private val accountsApi: AccountsApi,
private val unauthenticatedAccountsApi: UnauthenticatedAccountsApi,
private val authenticatedAccountsApi: AuthenticatedAccountsApi,
private val unauthenticatedKeyConnectorApi: UnauthenticatedKeyConnectorApi,
private val authenticatedKeyConnectorApi: AuthenticatedKeyConnectorApi,
private val json: Json,
) : AccountsService {
/**
* Converts the currently active account to a key-connector account.
*/
override suspend fun convertToKeyConnector(): Result<Unit> =
authenticatedAccountsApi.convertToKeyConnector()
override suspend fun createAccountKeys(
publicKey: String,
encryptedPrivateKey: String,
@@ -69,7 +87,7 @@ class AccountsServiceImpl(
override suspend fun requestPasswordHint(
email: String,
): Result<PasswordHintResponseJson> =
accountsApi
unauthenticatedAccountsApi
.passwordHintRequest(PasswordHintRequestJson(email))
.map { PasswordHintResponseJson.Success }
.recoverCatching { throwable ->
@@ -83,7 +101,7 @@ class AccountsServiceImpl(
}
override suspend fun resendVerificationCodeEmail(body: ResendEmailRequestJson): Result<Unit> =
accountsApi.resendVerificationCodeEmail(body = body)
unauthenticatedAccountsApi.resendVerificationCodeEmail(body = body)
override suspend fun resetPassword(body: ResetPasswordRequestJson): Result<Unit> {
return if (body.currentPasswordHash == null) {
@@ -93,7 +111,44 @@ class AccountsServiceImpl(
}
}
override suspend fun setKeyConnectorKey(
accessToken: String,
body: KeyConnectorKeyRequestJson,
): Result<Unit> = unauthenticatedAccountsApi.setKeyConnectorKey(
body = body,
bearerToken = "$HEADER_VALUE_BEARER_PREFIX$accessToken",
)
override suspend fun setPassword(
body: SetPasswordRequestJson,
): Result<Unit> = authenticatedAccountsApi.setPassword(body)
override suspend fun getMasterKeyFromKeyConnector(
url: String,
accessToken: String,
): Result<KeyConnectorMasterKeyResponseJson> =
unauthenticatedKeyConnectorApi.getMasterKeyFromKeyConnector(
url = "$url/user-keys",
bearerToken = "$HEADER_VALUE_BEARER_PREFIX$accessToken",
)
override suspend fun storeMasterKeyToKeyConnector(
url: String,
masterKey: String,
): Result<Unit> =
authenticatedKeyConnectorApi.storeMasterKeyToKeyConnector(
url = "$url/user-keys",
body = KeyConnectorMasterKeyRequestJson(masterKey = masterKey),
)
override suspend fun storeMasterKeyToKeyConnector(
url: String,
accessToken: String,
masterKey: String,
): Result<Unit> =
unauthenticatedKeyConnectorApi.storeMasterKeyToKeyConnector(
url = "$url/user-keys",
bearerToken = "$HEADER_VALUE_BEARER_PREFIX$accessToken",
body = KeyConnectorMasterKeyRequestJson(masterKey = masterKey),
)
}

View File

@@ -5,8 +5,10 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.IdentityTokenAuthM
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
/**
@@ -58,4 +60,16 @@ interface IdentityService {
* @param refreshToken The refresh token needed to obtain a new token.
*/
fun refreshTokenSynchronously(refreshToken: String): Result<RefreshTokenResponseJson>
/**
* Send a verification email.
*/
suspend fun sendVerificationEmail(
body: SendVerificationEmailRequestJson,
): Result<String?>
/**
* Register a new account to Bitwarden using email verification flow.
*/
suspend fun registerFinish(body: RegisterFinishRequestJson): Result<RegisterResponseJson>
}

View File

@@ -1,14 +1,16 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.IdentityApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.UnauthenticatedIdentityApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.IdentityTokenAuthModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PreLoginResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PrevalidateSsoResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorDataModel
import com.x8bit.bitwarden.data.platform.datasource.network.model.toBitwardenError
import com.x8bit.bitwarden.data.platform.datasource.network.util.base64UrlEncode
@@ -18,30 +20,30 @@ import com.x8bit.bitwarden.data.platform.util.DeviceModelProvider
import kotlinx.serialization.json.Json
class IdentityServiceImpl(
private val api: IdentityApi,
private val unauthenticatedIdentityApi: UnauthenticatedIdentityApi,
private val json: Json,
private val deviceModelProvider: DeviceModelProvider = DeviceModelProvider(),
) : IdentityService {
override suspend fun preLogin(email: String): Result<PreLoginResponseJson> =
api.preLogin(PreLoginRequestJson(email = email))
unauthenticatedIdentityApi.preLogin(PreLoginRequestJson(email = email))
@Suppress("MagicNumber")
override suspend fun register(body: RegisterRequestJson): Result<RegisterResponseJson> =
api
unauthenticatedIdentityApi
.register(body)
.recoverCatching { throwable ->
val bitwardenError = throwable.toBitwardenError()
bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.CaptchaRequired>(
code = 400,
json = json,
) ?: bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.Invalid>(
codes = listOf(400, 429),
json = json,
) ?: bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.Error>(
code = 429,
json = json,
) ?: throw throwable
bitwardenError
.parseErrorBodyOrNull<RegisterResponseJson.CaptchaRequired>(
code = 400,
json = json,
)
?: bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.Invalid>(
codes = listOf(400, 429),
json = json,
)
?: throw throwable
}
@Suppress("MagicNumber")
@@ -51,7 +53,7 @@ class IdentityServiceImpl(
authModel: IdentityTokenAuthModel,
captchaToken: String?,
twoFactorData: TwoFactorDataModel?,
): Result<GetTokenResponseJson> = api
): Result<GetTokenResponseJson> = unauthenticatedIdentityApi
.getToken(
scope = "api offline_access",
clientId = "mobile",
@@ -87,18 +89,42 @@ class IdentityServiceImpl(
override suspend fun prevalidateSso(
organizationIdentifier: String,
): Result<PrevalidateSsoResponseJson> = api
): Result<PrevalidateSsoResponseJson> = unauthenticatedIdentityApi
.prevalidateSso(
organizationIdentifier = organizationIdentifier,
)
override fun refreshTokenSynchronously(
refreshToken: String,
): Result<RefreshTokenResponseJson> = api
): Result<RefreshTokenResponseJson> = unauthenticatedIdentityApi
.refreshTokenCall(
clientId = "mobile",
grantType = "refresh_token",
refreshToken = refreshToken,
)
.executeForResult()
@Suppress("MagicNumber")
override suspend fun registerFinish(
body: RegisterFinishRequestJson,
): Result<RegisterResponseJson> =
unauthenticatedIdentityApi
.registerFinish(body)
.recoverCatching { throwable ->
val bitwardenError = throwable.toBitwardenError()
bitwardenError
.parseErrorBodyOrNull<RegisterResponseJson.Invalid>(
codes = listOf(400, 429),
json = json,
)
?: throw throwable
}
override suspend fun sendVerificationEmail(
body: SendVerificationEmailRequestJson,
): Result<String?> {
return unauthenticatedIdentityApi
.sendVerificationEmail(body = body)
.map { it?.content }
}
}

View File

@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.network.service
import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedOrganizationApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.OrganizationApi
import com.x8bit.bitwarden.data.auth.datasource.network.api.UnauthenticatedOrganizationApi
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationAutoEnrollStatusResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationDomainSsoDetailsResponseJson
@@ -13,7 +13,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.OrganizationResetP
*/
class OrganizationServiceImpl(
private val authenticatedOrganizationApi: AuthenticatedOrganizationApi,
private val organizationApi: OrganizationApi,
private val unauthenticatedOrganizationApi: UnauthenticatedOrganizationApi,
) : OrganizationService {
override suspend fun organizationResetPasswordEnroll(
organizationId: String,
@@ -32,7 +32,7 @@ class OrganizationServiceImpl(
override suspend fun getOrganizationDomainSsoDetails(
email: String,
): Result<OrganizationDomainSsoDetailsResponseJson> = organizationApi
): Result<OrganizationDomainSsoDetailsResponseJson> = unauthenticatedOrganizationApi
.getClaimedDomainOrganizationDetails(
body = OrganizationDomainSsoDetailsRequestJson(
email = email,

View File

@@ -1,9 +1,10 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.bitwarden.AuthRequestResponse
import com.bitwarden.bitwarden.MasterPasswordPolicyOptions
import com.bitwarden.bitwarden.RegisterKeyResponse
import com.bitwarden.bitwarden.RegisterTdeKeyResponse
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
@@ -37,6 +38,11 @@ interface AuthSdkSource {
purpose: HashPurpose,
): Result<String>
/**
* Creates a set of encryption key information for use with a key connector.
*/
suspend fun makeKeyConnectorKeys(): Result<KeyConnectorResponse>
/**
* Creates a set of encryption key information for registration.
*/

View File

@@ -1,17 +1,18 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk
import com.bitwarden.bitwarden.AuthRequestResponse
import com.bitwarden.bitwarden.FingerprintRequest
import com.bitwarden.bitwarden.MasterPasswordPolicyOptions
import com.bitwarden.bitwarden.RegisterKeyResponse
import com.bitwarden.bitwarden.RegisterTdeKeyResponse
import com.bitwarden.core.AuthRequestResponse
import com.bitwarden.core.FingerprintRequest
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.sdk.Client
import com.bitwarden.sdk.ClientAuth
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toPasswordStrengthOrNull
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toUByte
import com.x8bit.bitwarden.data.platform.datasource.sdk.BaseSdkSource
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
/**
@@ -19,12 +20,13 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
* [ClientAuth].
*/
class AuthSdkSourceImpl(
private val sdkClientManager: SdkClientManager,
) : AuthSdkSource {
sdkClientManager: SdkClientManager,
) : BaseSdkSource(sdkClientManager = sdkClientManager),
AuthSdkSource {
override suspend fun getNewAuthRequest(
email: String,
): Result<AuthRequestResponse> = runCatching {
): Result<AuthRequestResponse> = runCatchingWithLogs {
getClient()
.auth()
.newAuthRequest(
@@ -35,7 +37,7 @@ class AuthSdkSourceImpl(
override suspend fun getUserFingerprint(
email: String,
publicKey: String,
): Result<String> = runCatching {
): Result<String> = runCatchingWithLogs {
getClient()
.platform()
.fingerprint(
@@ -51,7 +53,7 @@ class AuthSdkSourceImpl(
password: String,
kdf: Kdf,
purpose: HashPurpose,
): Result<String> = runCatching {
): Result<String> = runCatchingWithLogs {
getClient()
.auth()
.hashPassword(
@@ -62,11 +64,18 @@ class AuthSdkSourceImpl(
)
}
override suspend fun makeKeyConnectorKeys(): Result<KeyConnectorResponse> =
runCatchingWithLogs {
getClient()
.auth()
.makeKeyConnectorKeys()
}
override suspend fun makeRegisterKeys(
email: String,
password: String,
kdf: Kdf,
): Result<RegisterKeyResponse> = runCatching {
): Result<RegisterKeyResponse> = runCatchingWithLogs {
getClient()
.auth()
.makeRegisterKeys(
@@ -81,7 +90,7 @@ class AuthSdkSourceImpl(
email: String,
orgPublicKey: String,
rememberDevice: Boolean,
): Result<RegisterTdeKeyResponse> = runCatching {
): Result<RegisterTdeKeyResponse> = runCatchingWithLogs {
getClient(userId = userId)
.auth()
.makeRegisterTdeKeys(
@@ -95,7 +104,7 @@ class AuthSdkSourceImpl(
email: String,
password: String,
additionalInputs: List<String>,
): Result<PasswordStrength> = runCatching {
): Result<PasswordStrength> = runCatchingWithLogs {
@Suppress("UnsafeCallOnNullableType")
getClient()
.auth()
@@ -111,7 +120,7 @@ class AuthSdkSourceImpl(
password: String,
passwordStrength: PasswordStrength,
policy: MasterPasswordPolicyOptions,
): Result<Boolean> = runCatching {
): Result<Boolean> = runCatchingWithLogs {
getClient()
.auth()
.satisfiesPolicy(
@@ -120,8 +129,4 @@ class AuthSdkSourceImpl(
policy = policy,
)
}
private suspend fun getClient(
userId: String? = null,
): Client = sdkClientManager.getOrCreateClient(userId = userId)
}

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.bitwarden.AuthRequestResponse
import com.bitwarden.core.AuthRequestResponse
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.AuthRequestTypeJson

View File

@@ -0,0 +1,46 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyResponseJson
/**
* Manager used to interface with a key connector.
*/
interface KeyConnectorManager {
/**
* Retrieves the master key from the key connector.
*/
suspend fun getMasterKeyFromKeyConnector(
url: String,
accessToken: String,
): Result<KeyConnectorMasterKeyResponseJson>
/**
* Migrates an existing user to use the key connector.
*/
@Suppress("LongParameterList")
suspend fun migrateExistingUserToKeyConnector(
userId: String,
url: String,
userKeyEncrypted: String,
email: String,
masterPassword: String,
kdf: Kdf,
): Result<Unit>
/**
* Migrates a new user to use the key connector.
*/
@Suppress("LongParameterList")
suspend fun migrateNewUserToKeyConnector(
url: String,
accessToken: String,
kdfType: KdfTypeJson,
kdfIterations: Int?,
kdfMemory: Int?,
kdfParallelism: Int?,
organizationIdentifier: String,
): Result<KeyConnectorResponse>
}

View File

@@ -0,0 +1,88 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorKeyRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.KeyConnectorMasterKeyResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
/**
* The default implementation of the [KeyConnectorManager].
*/
class KeyConnectorManagerImpl(
private val accountsService: AccountsService,
private val authSdkSource: AuthSdkSource,
private val vaultSdkSource: VaultSdkSource,
) : KeyConnectorManager {
override suspend fun getMasterKeyFromKeyConnector(
url: String,
accessToken: String,
): Result<KeyConnectorMasterKeyResponseJson> =
accountsService.getMasterKeyFromKeyConnector(
url = url,
accessToken = accessToken,
)
override suspend fun migrateExistingUserToKeyConnector(
userId: String,
url: String,
userKeyEncrypted: String,
email: String,
masterPassword: String,
kdf: Kdf,
): Result<Unit> =
vaultSdkSource
.deriveKeyConnector(
userId = userId,
userKeyEncrypted = userKeyEncrypted,
email = email,
password = masterPassword,
kdf = kdf,
)
.flatMap { masterKey ->
accountsService.storeMasterKeyToKeyConnector(url = url, masterKey = masterKey)
}
.flatMap { accountsService.convertToKeyConnector() }
override suspend fun migrateNewUserToKeyConnector(
url: String,
accessToken: String,
kdfType: KdfTypeJson,
kdfIterations: Int?,
kdfMemory: Int?,
kdfParallelism: Int?,
organizationIdentifier: String,
): Result<KeyConnectorResponse> =
authSdkSource
.makeKeyConnectorKeys()
.flatMap { keyConnectorResponse ->
accountsService
.storeMasterKeyToKeyConnector(
url = url,
accessToken = accessToken,
masterKey = keyConnectorResponse.masterKey,
)
.flatMap {
accountsService.setKeyConnectorKey(
accessToken = accessToken,
body = KeyConnectorKeyRequestJson(
userKey = keyConnectorResponse.encryptedUserKey,
keys = KeyConnectorKeyRequestJson.Keys(
publicKey = keyConnectorResponse.keys.public,
encryptedPrivateKey = keyConnectorResponse.keys.private,
),
kdfType = kdfType,
kdfIterations = kdfIterations,
kdfMemory = kdfMemory,
kdfParallelism = kdfParallelism,
organizationIdentifier = organizationIdentifier,
),
)
}
.map { keyConnectorResponse }
}
}

View File

@@ -17,7 +17,8 @@ class TrustedDeviceManagerImpl(
private val devicesService: DevicesService,
) : TrustedDeviceManager {
override suspend fun trustThisDeviceIfNecessary(userId: String): Result<Boolean> =
if (!authDiskSource.getShouldTrustDevice(userId = userId)) {
if (authDiskSource.getShouldTrustDevice(userId = userId) != true) {
authDiskSource.storeIsTdeLoginComplete(userId = userId, isTdeLoginComplete = true)
false.asSuccess()
} else {
vaultSdkSource
@@ -51,6 +52,7 @@ class TrustedDeviceManagerImpl(
userId = userId,
previousUserState = requireNotNull(authDiskSource.userState),
)
authDiskSource.storeIsTdeLoginComplete(userId = userId, isTdeLoginComplete = true)
}
.also { authDiskSource.storeShouldTrustDevice(userId = userId, shouldTrustDevice = null) }
.map { Unit }

View File

@@ -1,9 +1,18 @@
package com.x8bit.bitwarden.data.auth.manager
import com.x8bit.bitwarden.data.auth.manager.model.LogoutEvent
import kotlinx.coroutines.flow.SharedFlow
/**
* Manages the logging out of users and clearing of their data.
*/
interface UserLogoutManager {
/**
* Observable flow of [LogoutEvent]s
*/
val logoutEventFlow: SharedFlow<LogoutEvent>
/**
* Completely logs out the given [userId], removing all data.
* If [isExpired] is true, a toast will be displayed

View File

@@ -5,14 +5,19 @@ import android.widget.Toast
import androidx.annotation.StringRes
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.model.LogoutEvent
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.GeneratorDiskSource
import com.x8bit.bitwarden.data.tools.generator.datasource.disk.PasswordHistoryDiskSource
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
/**
@@ -33,43 +38,30 @@ class UserLogoutManagerImpl(
private val scope = CoroutineScope(dispatcherManager.unconfined)
private val mainScope = CoroutineScope(dispatcherManager.main)
private val mutableLogoutEventFlow: MutableSharedFlow<LogoutEvent> =
bufferedMutableSharedFlow()
override val logoutEventFlow: SharedFlow<LogoutEvent> = mutableLogoutEventFlow.asSharedFlow()
override fun logout(userId: String, isExpired: Boolean) {
val currentUserState = authDiskSource.userState ?: return
authDiskSource.userState ?: return
if (isExpired) {
showToast(message = R.string.login_expired)
}
// Remove the active user from the accounts map
val updatedAccounts = currentUserState
.accounts
.filterKeys { it != userId }
val ableToSwitchToNewAccount = switchUserIfAvailable(
currentUserId = userId,
isExpired = isExpired,
removeCurrentUserFromAccounts = true,
)
// Check if there is a new active user
if (updatedAccounts.isNotEmpty()) {
if (userId == currentUserState.activeUserId && !isExpired) {
showToast(message = R.string.account_switched_automatically)
}
// If we logged out a non-active user, we want to leave the active user unchanged.
// If we logged out the active user, we want to set the active user to the first one
// in the list.
val updatedActiveUserId = currentUserState
.activeUserId
.takeUnless { it == userId }
?: updatedAccounts.entries.first().key
// Update the user information and emit an updated token
authDiskSource.userState = currentUserState.copy(
activeUserId = updatedActiveUserId,
accounts = updatedAccounts,
)
} else {
if (!ableToSwitchToNewAccount) {
// Update the user information and log out
authDiskSource.userState = null
}
clearData(userId = userId)
mutableLogoutEventFlow.tryEmit(LogoutEvent(loggedOutUserId = userId))
}
override fun softLogout(userId: String) {
@@ -82,7 +74,10 @@ class UserLogoutManagerImpl(
val vaultTimeoutInMinutes = settingsDiskSource.getVaultTimeoutInMinutes(userId = userId)
val vaultTimeoutAction = settingsDiskSource.getVaultTimeoutAction(userId = userId)
switchUserIfAvailable(currentUserId = userId, removeCurrentUserFromAccounts = false)
clearData(userId = userId)
mutableLogoutEventFlow.tryEmit(LogoutEvent(loggedOutUserId = userId))
// Restore data that is still required
settingsDiskSource.apply {
@@ -112,4 +107,46 @@ class UserLogoutManagerImpl(
private fun showToast(@StringRes message: Int) {
mainScope.launch { Toast.makeText(context, message, Toast.LENGTH_SHORT).show() }
}
private fun switchUserIfAvailable(
currentUserId: String,
removeCurrentUserFromAccounts: Boolean,
isExpired: Boolean = false,
): Boolean {
val currentUserState = authDiskSource.userState ?: return false
val currentAccountsMap = currentUserState.accounts
// Remove the active user from the accounts map
val updatedAccounts = currentAccountsMap
.filterKeys { it != currentUserId }
// Check if there is a new active user
return if (updatedAccounts.isNotEmpty()) {
if (currentUserId == currentUserState.activeUserId && !isExpired) {
showToast(message = R.string.account_switched_automatically)
}
// If we logged out a non-active user, we want to leave the active user unchanged.
// If we logged out the active user, we want to set the active user to the first one
// in the list.
val updatedActiveUserId = currentUserState
.activeUserId
.takeUnless { it == currentUserId }
?: updatedAccounts.entries.first().key
// Update the user information and emit an updated token
authDiskSource.userState = currentUserState.copy(
activeUserId = updatedActiveUserId,
accounts = if (removeCurrentUserFromAccounts) {
updatedAccounts
} else {
currentAccountsMap
},
)
true
} else {
false
}
}
}

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.manager.di
import android.content.Context
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.AuthRequestsService
import com.x8bit.bitwarden.data.auth.datasource.network.service.DevicesService
import com.x8bit.bitwarden.data.auth.datasource.network.service.NewAuthRequestService
@@ -10,6 +11,8 @@ import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManagerImpl
import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManager
import com.x8bit.bitwarden.data.auth.manager.AuthRequestNotificationManagerImpl
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManagerImpl
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManagerImpl
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
@@ -71,6 +74,19 @@ object AuthManagerModule {
authDiskSource = authDiskSource,
)
@Provides
@Singleton
fun provideKeyConnectorManager(
accountsService: AccountsService,
authSdkSource: AuthSdkSource,
vaultSdkSource: VaultSdkSource,
): KeyConnectorManager =
KeyConnectorManagerImpl(
accountsService = accountsService,
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
)
@Provides
@Singleton
fun provideTrustedDeviceManager(

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.auth.manager.model
import com.bitwarden.bitwarden.AuthRequestResponse
import com.bitwarden.core.AuthRequestResponse
/**
* Models result of creating a new login approval request.

View File

@@ -0,0 +1,9 @@
package com.x8bit.bitwarden.data.auth.manager.model
/**
* Result class to share the [loggedOutUserId] of a user
* that was successfully logged out.
*/
data class LogoutEvent(
val loggedOutUserId: String,
)

View File

@@ -16,13 +16,16 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
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.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
@@ -100,6 +103,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
var rememberedOrgIdentifier: String?
/**
* The currently persisted state indicating whether the user has completed login via TDE.
*/
val tdeLoginComplete: Boolean?
/**
* The currently persisted state indicating whether the user has trusted this device.
*/
@@ -129,6 +137,12 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
val organizations: List<SyncResponseJson.Profile.Organization>
/**
* Whether or not the welcome carousel should be displayed, based on the feature flag and
* whether the user has ever logged in or created an account before.
*/
val showWelcomeCarousel: Boolean
/**
* Clears the pending deletion state that occurs when the an account is successfully deleted.
*/
@@ -238,11 +252,6 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
fun switchAccount(userId: String): SwitchAccountResult
/**
* Updates the "last active time" for the current user.
*/
fun updateLastActiveTime()
/**
* Attempt to register a new account with the given parameters.
*/
@@ -251,6 +260,7 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
email: String,
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String? = null,
captchaToken: String?,
shouldCheckDataBreaches: Boolean,
isMasterPasswordStrong: Boolean,
@@ -263,6 +273,12 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
email: String,
): PasswordHintResult
/**
* Removes the users password from the account. This used used when migrating from master
* password login to key connector login.
*/
suspend fun removePassword(masterPassword: String): RemovePasswordResult
/**
* Resets the users password from the [currentPassword] (or null for account recovery resets),
* to the [newPassword] and optional [passwordHint].
@@ -342,9 +358,23 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
suspend fun validatePassword(password: String): ValidatePasswordResult
/**
* Validates the PIN for the current logged in user.
*/
suspend fun validatePin(pin: String): ValidatePinResult
/**
* Validates the given [password] against the master password
* policies for the current user.
*/
suspend fun validatePasswordAgainstPolicies(password: String): Boolean
/**
* Send a verification email.
*/
suspend fun sendVerificationEmail(
email: String,
name: String,
receiveMarketingEmails: Boolean,
): SendVerificationEmailResult
}

View File

@@ -1,11 +1,12 @@
package com.x8bit.bitwarden.data.auth.repository
import android.os.SystemClock
import com.bitwarden.bitwarden.AuthRequestMethod
import com.bitwarden.bitwarden.InitUserCryptoMethod
import com.bitwarden.core.AuthRequestMethod
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.InitUserCryptoRequest
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
@@ -15,10 +16,12 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.GetTokenResponseJs
import com.x8bit.bitwarden.data.auth.datasource.network.model.IdentityTokenAuthModel
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterFinishRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RegisterResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SendVerificationEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.TwoFactorAuthMethod
@@ -32,6 +35,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toInt
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
@@ -46,38 +50,47 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePinResult
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.auth.repository.model.VerifyOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.toLoginErrorResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJsonWithPassword
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokensFlow
import com.x8bit.bitwarden.data.auth.repository.util.userKeyConnectorStateFlow
import com.x8bit.bitwarden.data.auth.repository.util.userKeyConnectorStateList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsListFlow
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS
import com.x8bit.bitwarden.data.auth.util.YubiKeyResult
import com.x8bit.bitwarden.data.auth.util.toSdkParams
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@@ -85,11 +98,14 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl
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.vault.datasource.network.model.OrganizationType
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.InitializeCryptoResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -133,12 +149,13 @@ class AuthRepositoryImpl(
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
private val authRequestManager: AuthRequestManager,
private val keyConnectorManager: KeyConnectorManager,
private val trustedDeviceManager: TrustedDeviceManager,
private val userLogoutManager: UserLogoutManager,
private val policyManager: PolicyManager,
private val featureFlagManager: FeatureFlagManager,
pushManager: PushManager,
dispatcherManager: DispatcherManager,
private val elapsedRealtimeMillisProvider: () -> Long = { SystemClock.elapsedRealtime() },
) : AuthRepository,
AuthRequestManager by authRequestManager {
/**
@@ -229,6 +246,7 @@ class AuthRepositoryImpl(
authDiskSource.userStateFlow,
authDiskSource.userAccountTokensFlow,
authDiskSource.userOrganizationsListFlow,
authDiskSource.userKeyConnectorStateFlow,
vaultRepository.vaultUnlockDataStateFlow,
mutableHasPendingAccountAdditionStateFlow,
// Ignore the data in the merge, but trigger an update when they emit.
@@ -240,12 +258,14 @@ class AuthRepositoryImpl(
val userStateJson = array[0] as UserStateJson?
val userAccountTokens = array[1] as List<UserAccountTokens>
val userOrganizationsList = array[2] as List<UserOrganizations>
val vaultState = array[3] as List<VaultUnlockData>
val hasPendingAccountAddition = array[4] as Boolean
val userIsUsingKeyConnectorList = array[3] as List<UserKeyConnectorState>
val vaultState = array[4] as List<VaultUnlockData>
val hasPendingAccountAddition = array[5] as Boolean
userStateJson?.toUserState(
vaultState = vaultState,
userAccountTokens = userAccountTokens,
userOrganizationsList = userOrganizationsList,
userIsUsingKeyConnectorList = userIsUsingKeyConnectorList,
hasPendingAccountAddition = hasPendingAccountAddition,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
@@ -263,6 +283,7 @@ class AuthRepositoryImpl(
vaultState = vaultRepository.vaultUnlockDataStateFlow.value,
userAccountTokens = authDiskSource.userAccountTokens,
userOrganizationsList = authDiskSource.userOrganizationsList,
userIsUsingKeyConnectorList = authDiskSource.userKeyConnectorStateList,
hasPendingAccountAddition = mutableHasPendingAccountAdditionStateFlow.value,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
@@ -291,6 +312,9 @@ class AuthRepositoryImpl(
override var rememberedOrgIdentifier: String? by authDiskSource::rememberedOrgIdentifier
override val tdeLoginComplete: Boolean?
get() = activeUserId?.let { authDiskSource.getIsTdeLoginComplete(userId = it) }
override var shouldTrustDevice: Boolean
get() = activeUserId?.let { authDiskSource.getShouldTrustDevice(userId = it) } ?: false
set(value) {
@@ -315,6 +339,10 @@ class AuthRepositoryImpl(
override val organizations: List<SyncResponseJson.Profile.Organization>
get() = activeUserId?.let { authDiskSource.getOrganizations(it) }.orEmpty()
override val showWelcomeCarousel: Boolean
get() = !settingsRepository.hasUserLoggedInOrCreatedAccount &&
featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel)
init {
pushManager
.syncOrgKeysFlow
@@ -443,7 +471,6 @@ class AuthRepositoryImpl(
},
)
@Suppress("ReturnCount")
override suspend fun createNewSsoUser(): NewSsoUserResult {
val account = authDiskSource.userState?.activeAccount ?: return NewSsoUserResult.Failure
val orgIdentifier = rememberedOrgIdentifier ?: return NewSsoUserResult.Failure
@@ -458,7 +485,8 @@ class AuthRepositoryImpl(
userId = userId,
email = account.profile.email,
orgPublicKey = organizationKeys.publicKey,
rememberDevice = authDiskSource.getShouldTrustDevice(userId = userId),
rememberDevice = authDiskSource
.getShouldTrustDevice(userId = userId) == true,
)
}
.flatMap { keys ->
@@ -501,7 +529,6 @@ class AuthRepositoryImpl(
)
}
@Suppress("ReturnCount")
override suspend fun completeTdeLogin(
requestPrivateKey: String,
asymmetricalKey: String,
@@ -511,20 +538,22 @@ class AuthRepositoryImpl(
val userId = profile.userId
val privateKey = authDiskSource.getPrivateKey(userId = userId)
?: return LoginResult.Error(errorMessage = null)
vaultRepository.unlockVault(
userId = userId,
email = profile.email,
kdf = profile.toSdkParams(),
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = requestPrivateKey,
method = AuthRequestMethod.UserKey(protectedUserKey = asymmetricalKey),
),
// We should already have the org keys from the login sync.
organizationKeys = authDiskSource.getOrganizationKeys(userId = userId),
)
authDiskSource.storeUserKey(userId = userId, userKey = asymmetricalKey)
checkForVaultUnlockError(
onVaultUnlockError = { error ->
return error.toLoginErrorResult()
},
) {
unlockVault(
accountProfile = profile,
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = requestPrivateKey,
method = AuthRequestMethod.UserKey(protectedUserKey = asymmetricalKey),
),
)
}
vaultRepository.syncIfNecessary()
return LoginResult.Success
}
@@ -627,19 +656,24 @@ class AuthRepositoryImpl(
?: return IllegalStateException("Must be logged in.").asFailure()
return identityService
.refreshTokenSynchronously(refreshToken)
.onSuccess {
.flatMap { refreshTokenResponse ->
// Check to make sure the user is still logged in after making the request
authDiskSource
.userState
?.accounts
?.get(userId)
?.let { refreshTokenResponse.asSuccess() }
?: IllegalStateException("Must be logged in.").asFailure()
}
.onSuccess { refreshTokenResponse ->
// Update the existing UserState with updated token information
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = it.accessToken,
refreshToken = it.refreshToken,
accessToken = refreshTokenResponse.accessToken,
refreshToken = refreshTokenResponse.refreshToken,
),
)
authDiskSource.userState = it.toUserStateJson(
userId = userId,
previousUserState = requireNotNull(authDiskSource.userState),
)
}
}
@@ -678,7 +712,6 @@ class AuthRepositoryImpl(
}
?: ResendEmailResult.Error(message = null)
@Suppress("ReturnCount")
override fun switchAccount(userId: String): SwitchAccountResult {
val currentUserState = authDiskSource.userState ?: return SwitchAccountResult.NoChange
val previousActiveUserId = currentUserState.activeUserId
@@ -703,19 +736,12 @@ class AuthRepositoryImpl(
return SwitchAccountResult.AccountSwitched
}
override fun updateLastActiveTime() {
val userId = activeUserId ?: return
authDiskSource.storeLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = elapsedRealtimeMillisProvider(),
)
}
@Suppress("ReturnCount", "LongMethod")
@Suppress("LongMethod")
override suspend fun register(
email: String,
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String?,
captchaToken: String?,
shouldCheckDataBreaches: Boolean,
isMasterPasswordStrong: Boolean,
@@ -744,21 +770,41 @@ class AuthRepositoryImpl(
kdf = kdf,
)
.flatMap { registerKeyResponse ->
identityService.register(
body = RegisterRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
captchaResponse = captchaToken,
key = registerKeyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
if (emailVerificationToken == null) {
// TODO PM-6675: Remove register call and service implementation
identityService.register(
body = RegisterRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
captchaResponse = captchaToken,
key = registerKeyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
)
)
} else {
identityService.registerFinish(
body = RegisterFinishRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
captchaResponse = captchaToken,
userSymmetricKey = registerKeyResponse.encryptedUserKey,
userAsymmetricKeys = RegisterFinishRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
)
}
}
.fold(
onSuccess = {
@@ -784,10 +830,6 @@ class AuthRepositoryImpl(
?: it.message,
)
}
is RegisterResponseJson.Error -> {
RegisterResult.Error(it.message)
}
}
},
onFailure = { RegisterResult.Error(errorMessage = null) },
@@ -806,7 +848,46 @@ class AuthRepositoryImpl(
)
}
@Suppress("ReturnCount")
override suspend fun removePassword(masterPassword: String): RemovePasswordResult {
val activeAccount = authDiskSource
.userState
?.activeAccount
?: return RemovePasswordResult.Error
val profile = activeAccount.profile
val userId = profile.userId
val userKey = authDiskSource
.getUserKey(userId = userId)
?: return RemovePasswordResult.Error
val keyConnectorUrl = organizations
.find {
it.shouldUseKeyConnector &&
it.type != OrganizationType.OWNER &&
it.type != OrganizationType.ADMIN
}
?.keyConnectorUrl
?: return RemovePasswordResult.Error
return keyConnectorManager
.migrateExistingUserToKeyConnector(
userId = userId,
url = keyConnectorUrl,
userKeyEncrypted = userKey,
email = profile.email,
masterPassword = masterPassword,
kdf = profile.toSdkParams(),
)
.onSuccess {
authDiskSource.userState = authDiskSource
.userState
?.toRemovedPasswordUserStateJson(userId = userId)
vaultRepository.sync()
settingsRepository.setDefaultsIfNecessary(userId = userId)
}
.fold(
onFailure = { RemovePasswordResult.Error },
onSuccess = { RemovePasswordResult.Success },
)
}
override suspend fun resetPassword(
currentPassword: String?,
newPassword: String,
@@ -954,9 +1035,9 @@ class AuthRepositoryImpl(
)
}
VaultUnlockResult.AuthenticationError,
VaultUnlockResult.GenericError,
is VaultUnlockResult.AuthenticationError,
VaultUnlockResult.InvalidStateError,
VaultUnlockResult.GenericError,
-> {
IllegalStateException("Failed to unlock vault").asFailure()
}
@@ -1059,7 +1140,6 @@ class AuthRepositoryImpl(
onFailure = { PasswordStrengthResult.Error },
)
@Suppress("ReturnCount")
override suspend fun validatePassword(password: String): ValidatePasswordResult {
val userId = activeUserId ?: return ValidatePasswordResult.Error
return authDiskSource
@@ -1104,12 +1184,79 @@ class AuthRepositoryImpl(
}
}
override suspend fun validatePin(pin: String): ValidatePinResult {
val activeAccount = authDiskSource
.userState
?.activeAccount
?.profile
?: return ValidatePinResult.Error
val privateKey = authDiskSource
.getPrivateKey(userId = activeAccount.userId)
?: return ValidatePinResult.Error
val pinProtectedUserKey = authDiskSource
.getPinProtectedUserKey(userId = activeAccount.userId)
?: return ValidatePinResult.Error
// HACK: As the SDK doesn't provide a way to directly validate the pin yet, we instead
// try to initialize the user crypto, and if it succeeds then the PIN is correct, otherwise
// the PIN is incorrect.
return vaultSdkSource
.initializeCrypto(
userId = activeAccount.userId,
request = InitUserCryptoRequest(
kdfParams = activeAccount.toSdkParams(),
email = activeAccount.email,
privateKey = privateKey,
method = InitUserCryptoMethod.Pin(
pin = pin,
pinProtectedUserKey = pinProtectedUserKey,
),
),
)
.fold(
onSuccess = {
when (it) {
InitializeCryptoResult.Success -> {
ValidatePinResult.Success(isValid = true)
}
is InitializeCryptoResult.AuthenticationError -> {
ValidatePinResult.Success(isValid = false)
}
}
},
onFailure = { ValidatePinResult.Error },
)
}
override suspend fun validatePasswordAgainstPolicies(
password: String,
): Boolean = passwordPolicies
.all { validatePasswordAgainstPolicy(password, it) }
@Suppress("CyclomaticComplexMethod", "ReturnCount")
override suspend fun sendVerificationEmail(
email: String,
name: String,
receiveMarketingEmails: Boolean,
): SendVerificationEmailResult =
identityService
.sendVerificationEmail(
SendVerificationEmailRequestJson(
email = email,
name = name,
receiveMarketingEmails = receiveMarketingEmails,
),
)
.fold(
onSuccess = {
SendVerificationEmailResult.Success(it)
},
onFailure = {
SendVerificationEmailResult.Error(null)
},
)
@Suppress("CyclomaticComplexMethod")
private suspend fun validatePasswordAgainstPolicy(
password: String,
policy: PolicyInformation.MasterPassword,
@@ -1317,7 +1464,65 @@ class AuthRepositoryImpl(
previousUserState = authDiskSource.userState,
environmentUrlData = environmentRepository.environment.environmentUrlData,
)
val userId = userStateJson.activeUserId
val profile = userStateJson.activeAccount.profile
val userId = profile.userId
checkForVaultUnlockError(
onVaultUnlockError = { vaultUnlockError ->
return@userStateTransaction vaultUnlockError.toLoginErrorResult()
},
) {
val keyConnectorUrl = loginResponse
.keyConnectorUrl
?: loginResponse
.userDecryptionOptions
?.keyConnectorUserDecryptionOptions
?.keyConnectorUrl
val isDeviceUnlockAvailable = deviceData != null ||
loginResponse.userDecryptionOptions?.trustedDeviceUserDecryptionOptions != null
// if possible attempt to unlock the vault with trusted device data
if (isDeviceUnlockAvailable) {
unlockVaultWithTdeOnLoginSuccess(
loginResponse = loginResponse,
profile = profile,
deviceData = deviceData,
)
} else if (keyConnectorUrl != null && orgIdentifier != null) {
unlockVaultWithKeyConnectorOnLoginSuccess(
profile = profile,
keyConnectorUrl = keyConnectorUrl,
orgIdentifier = orgIdentifier,
loginResponse = loginResponse,
)
} else {
unlockVaultWithPasswordOnLoginSuccess(
loginResponse = loginResponse,
profile = profile,
password = password,
)
}
}
password?.let {
// Save the master password hash.
authSdkSource
.hashPassword(
email = email,
password = it,
kdf = profile.toSdkParams(),
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
.onSuccess { passwordHash ->
authDiskSource.storeMasterPasswordHash(
userId = userId,
passwordHash = passwordHash,
)
}
// Cache the password to verify against any password policies after the sync completes.
passwordsToCheckMap.put(userId, it)
}
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
@@ -1332,8 +1537,11 @@ class AuthRepositoryImpl(
// when we completed the pending admin auth request.
authDiskSource.storeUserKey(userId = userId, userKey = it)
}
authDiskSource.storePrivateKey(userId = userId, privateKey = loginResponse.privateKey)
loginResponse.privateKey?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the key connector conversion.
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
}
// If the user just authenticated with a two-factor code and selected the option to
// remember it, then the API response will return a token that will be used in place
// of the two-factor code on the next login attempt.
@@ -1346,162 +1554,17 @@ class AuthRepositoryImpl(
organizationIdentifier = orgIdentifier
}
// Handle the Trusted Device Encryption flow
loginResponse.userDecryptionOptions?.trustedDeviceUserDecryptionOptions?.let { options ->
loginResponse.privateKey?.let { privateKey ->
handleLoginCommonSuccessTrustedDeviceUserDecryptionOptions(
trustedDeviceDecryptionOptions = options,
userStateJson = userStateJson,
privateKey = privateKey,
)
}
}
// Remove any cached data after successfully logging in.
identityTokenAuthModel = null
twoFactorResponse = null
resendEmailRequestJson = null
twoFactorDeviceData = null
// Attempt to unlock the vault with password if possible.
password?.let {
if (loginResponse.privateKey != null && loginResponse.key != null) {
vaultRepository.unlockVault(
userId = userId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
userKey = loginResponse.key,
privateKey = loginResponse.privateKey,
masterPassword = it,
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
}
// Save the master password hash.
authSdkSource
.hashPassword(
email = email,
password = it,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
.onSuccess { passwordHash ->
authDiskSource.storeMasterPasswordHash(
userId = userId,
passwordHash = passwordHash,
)
}
// Cache the password to verify against any password policies after the sync completes.
passwordsToCheckMap.put(userId, it)
}
// Attempt to unlock the vault with auth request if possible.
// These values will only be null during the Just-in-Time provisioning flow.
if (loginResponse.privateKey != null && loginResponse.key != null) {
deviceData?.let { model ->
vaultRepository.unlockVault(
userId = userId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = model.privateKey,
method = model
.masterPasswordHash
?.let {
AuthRequestMethod.MasterKey(
protectedMasterKey = model.asymmetricalKey,
authRequestKey = loginResponse.key,
)
}
?: AuthRequestMethod.UserKey(protectedUserKey = model.asymmetricalKey),
),
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
// We are purposely not storing the master password hash here since it is not
// formatted in in a manner that we can use. We will store it properly the next
// time the user enters their master password and it is validated.
}
}
settingsRepository.setDefaultsIfNecessary(userId = userId)
vaultRepository.syncIfNecessary()
hasPendingAccountAddition = false
LoginResult.Success
}
/**
* A helper method to handle the [TrustedDeviceUserDecryptionOptionsJson] specific to TDE.
*/
@Suppress("ReturnCount")
private suspend fun handleLoginCommonSuccessTrustedDeviceUserDecryptionOptions(
trustedDeviceDecryptionOptions: TrustedDeviceUserDecryptionOptionsJson,
userStateJson: UserStateJson,
privateKey: String,
) {
val userId = userStateJson.activeUserId
val deviceKey = authDiskSource.getDeviceKey(userId = userId)
if (deviceKey == null) {
// A null device key means this device is not trusted.
val pendingRequest = authDiskSource.getPendingAuthRequest(userId = userId) ?: return
authRequestManager
.getAuthRequestIfApproved(pendingRequest.requestId)
.getOrNull()
?.let { request ->
// For approved requests the key will always be present.
val userKey = requireNotNull(request.key)
vaultRepository.unlockVault(
userId = userId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = pendingRequest.requestPrivateKey,
method = AuthRequestMethod.UserKey(protectedUserKey = userKey),
),
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
authDiskSource.storeUserKey(userId = userId, userKey = userKey)
}
authDiskSource.storePendingAuthRequest(
userId = userId,
pendingAuthRequest = null,
)
return
}
val encryptedPrivateKey = trustedDeviceDecryptionOptions.encryptedPrivateKey
val encryptedUserKey = trustedDeviceDecryptionOptions.encryptedUserKey
if (encryptedPrivateKey == null || encryptedUserKey == null) {
// If we have a device key but server is missing private key and user key, we
// need to clear the device key and let the user go through the TDE flow again.
authDiskSource.storeDeviceKey(userId = userId, deviceKey = null)
return
}
vaultRepository.unlockVault(
userId = userId,
email = userStateJson.activeAccount.profile.email,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(
deviceKey = deviceKey,
protectedDevicePrivateKey = encryptedPrivateKey,
deviceProtectedUserKey = encryptedUserKey,
),
// We can separately unlock vault for organization data after
// receiving the sync response if this data is currently absent.
organizationKeys = null,
)
authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey)
}
/**
* A helper method that processes the [GetTokenResponseJson.TwoFactorRequired] when logging in.
*/
@@ -1527,6 +1590,259 @@ class AuthRepositoryImpl(
return LoginResult.TwoFactorRequired
}
/**
* Attempt to unlock the current user's vault with key connector data.
*/
private suspend fun unlockVaultWithKeyConnectorOnLoginSuccess(
profile: AccountJson.Profile,
keyConnectorUrl: String,
orgIdentifier: String,
loginResponse: GetTokenResponseJson.Success,
): VaultUnlockResult? =
if (loginResponse.userDecryptionOptions?.hasMasterPassword != false) {
// This user has a master password, so we skip the key-connector logic as it is not
// setup yet. The user can still unlock the vault with their master password.
null
} else if (loginResponse.key != null && loginResponse.privateKey != null) {
// This is a returning user who should already have the key connector setup
keyConnectorManager
.getMasterKeyFromKeyConnector(
url = keyConnectorUrl,
accessToken = loginResponse.accessToken,
)
.map {
unlockVault(
accountProfile = profile,
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = it.masterKey,
userKey = loginResponse.key,
),
)
}
.fold(
// If the request failed, we want to abort the login process
onFailure = { VaultUnlockResult.GenericError },
onSuccess = { it },
)
} else {
// This is a new user who needs to setup the key connector
keyConnectorManager
.migrateNewUserToKeyConnector(
url = keyConnectorUrl,
accessToken = loginResponse.accessToken,
kdfType = loginResponse.kdfType,
kdfIterations = loginResponse.kdfIterations,
kdfMemory = loginResponse.kdfMemory,
kdfParallelism = loginResponse.kdfParallelism,
organizationIdentifier = orgIdentifier,
)
.map { keyConnectorResponse ->
val result = unlockVault(
accountProfile = profile,
privateKey = keyConnectorResponse.keys.private,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = keyConnectorResponse.masterKey,
userKey = keyConnectorResponse.encryptedUserKey,
),
)
if (result is VaultUnlockResult.Success) {
// We now know that login/unlock was successful, so we store the userKey
// and privateKey we now have since it didn't exist on the loginResponse
authDiskSource.storeUserKey(
userId = profile.userId,
userKey = keyConnectorResponse.encryptedUserKey,
)
authDiskSource.storePrivateKey(
userId = profile.userId,
privateKey = keyConnectorResponse.keys.private,
)
}
result
}
.fold(
// If the request failed, we want to abort the login process
onFailure = { VaultUnlockResult.GenericError },
onSuccess = { it },
)
}
/**
* Attempt to unlock the current user's vault with password data.
*/
private suspend fun unlockVaultWithPasswordOnLoginSuccess(
loginResponse: GetTokenResponseJson.Success,
profile: AccountJson.Profile,
password: String?,
): VaultUnlockResult? {
// Attempt to unlock the vault with password if possible.
val masterPassword = password ?: return null
val privateKey = loginResponse.privateKey ?: return null
val key = loginResponse.key ?: return null
return unlockVault(
accountProfile = profile,
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.Password(
password = masterPassword,
userKey = key,
),
)
}
/**
* Attempt to unlock the current user's vault with trusted device specific data.
*/
private suspend fun unlockVaultWithTdeOnLoginSuccess(
loginResponse: GetTokenResponseJson.Success,
profile: AccountJson.Profile,
deviceData: DeviceDataModel?,
): VaultUnlockResult? {
// Attempt to unlock the vault with auth request if possible.
// These values will only be null during the Just-in-Time provisioning flow.
if (loginResponse.privateKey != null && loginResponse.key != null) {
deviceData?.let { model ->
return unlockVault(
accountProfile = profile,
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = model.privateKey,
method = model
.masterPasswordHash
?.let {
AuthRequestMethod.MasterKey(
protectedMasterKey = model.asymmetricalKey,
authRequestKey = loginResponse.key,
)
}
?: AuthRequestMethod.UserKey(protectedUserKey = model.asymmetricalKey),
),
)
// We are purposely not storing the master password hash here since it is not
// formatted in in a manner that we can use. We will store it properly the next
// time the user enters their master password and it is validated.
}
}
// Handle the Trusted Device Encryption flow
return loginResponse
.userDecryptionOptions
?.trustedDeviceUserDecryptionOptions
?.let { options ->
loginResponse.privateKey?.let { privateKey ->
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options = options,
profile = profile,
privateKey = privateKey,
)
}
}
}
/**
* A helper method to handle the [TrustedDeviceUserDecryptionOptionsJson] specific to TDE
* and store the necessary keys when appropriate.
*/
private suspend fun unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options: TrustedDeviceUserDecryptionOptionsJson,
profile: AccountJson.Profile,
privateKey: String,
): VaultUnlockResult? {
var vaultUnlockResult: VaultUnlockResult? = null
val userId = profile.userId
val deviceKey = authDiskSource.getDeviceKey(userId = userId)
if (deviceKey == null) {
// A null device key means this device is not trusted.
val pendingRequest = authDiskSource
.getPendingAuthRequest(userId = userId)
?: return null
authRequestManager
.getAuthRequestIfApproved(pendingRequest.requestId)
.getOrNull()
?.let { request ->
// For approved requests the key will always be present.
val userKey = requireNotNull(request.key)
vaultUnlockResult = unlockVault(
accountProfile = profile,
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = pendingRequest.requestPrivateKey,
method = AuthRequestMethod.UserKey(protectedUserKey = userKey),
),
)
authDiskSource.storeUserKey(userId = userId, userKey = userKey)
}
authDiskSource.storePendingAuthRequest(
userId = userId,
pendingAuthRequest = null,
)
return vaultUnlockResult
}
val encryptedPrivateKey = options.encryptedPrivateKey
val encryptedUserKey = options.encryptedUserKey
if (encryptedPrivateKey == null || encryptedUserKey == null) {
// If we have a device key but server is missing private key and user key, we
// need to clear the device key and let the user go through the TDE flow again.
authDiskSource.storeDeviceKey(userId = userId, deviceKey = null)
return null
}
vaultUnlockResult = unlockVault(
accountProfile = profile,
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(
deviceKey = deviceKey,
protectedDevicePrivateKey = encryptedPrivateKey,
deviceProtectedUserKey = encryptedUserKey,
),
)
if (vaultUnlockResult is VaultUnlockResult.Success) {
authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey)
}
return vaultUnlockResult
}
/**
* A helper function to unlock the vault for the user associated with the [accountProfile].
*/
private suspend fun unlockVault(
accountProfile: AccountJson.Profile,
privateKey: String,
initUserCryptoMethod: InitUserCryptoMethod,
): VaultUnlockResult {
val userId = accountProfile.userId
return vaultRepository.unlockVault(
userId = userId,
email = accountProfile.email,
kdf = accountProfile.toSdkParams(),
privateKey = privateKey,
initUserCryptoMethod = initUserCryptoMethod,
// The value for the organization keys here will typically be null. We can separately
// unlock the vault for organization data after receiving the sync response if this
// data is currently absent. These keys may be present during certain multi-phase login
// processes or if we needed to delete the user's token due to an encrypted data
// corruption issue and they are forced to log back in.
organizationKeys = authDiskSource.getOrganizationKeys(userId = userId),
)
}
/**
* A helper function to check for a vault unlock related error when logging in.
*
* @param onVaultUnlockError a lambda function to be invoked in the event a [VaultUnlockError]
* is produced via the passed in [block]
* @param block a lambda representing logic which produces either a [VaultUnlockResult] which
* is castable to [VaultUnlockError] or `null`
*/
private inline fun checkForVaultUnlockError(
onVaultUnlockError: (VaultUnlockError) -> Unit,
block: () -> VaultUnlockResult?,
) {
(block() as? VaultUnlockError)?.also(onVaultUnlockError)
}
//endregion LoginCommon
/**

View File

@@ -8,10 +8,12 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
@@ -34,7 +36,6 @@ object AuthRepositoryModule {
@Provides
@Singleton
@Suppress("LongParameterList")
fun providesAuthRepository(
accountsService: AccountsService,
devicesService: DevicesService,
@@ -48,11 +49,13 @@ object AuthRepositoryModule {
environmentRepository: EnvironmentRepository,
settingsRepository: SettingsRepository,
vaultRepository: VaultRepository,
keyConnectorManager: KeyConnectorManager,
authRequestManager: AuthRequestManager,
trustedDeviceManager: TrustedDeviceManager,
userLogoutManager: UserLogoutManager,
pushManager: PushManager,
policyManager: PolicyManager,
featureFlagManager: FeatureFlagManager,
): AuthRepository = AuthRepositoryImpl(
accountsService = accountsService,
devicesService = devicesService,
@@ -66,10 +69,12 @@ object AuthRepositoryModule {
environmentRepository = environmentRepository,
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
keyConnectorManager = keyConnectorManager,
authRequestManager = authRequestManager,
trustedDeviceManager = trustedDeviceManager,
userLogoutManager = userLogoutManager,
pushManager = pushManager,
policyManager = policyManager,
featureFlagManager = featureFlagManager,
)
}

View File

@@ -37,4 +37,10 @@ data class JwtTokenDataJson(
@SerialName("amr")
val authenticationMethodsReference: List<String>,
)
) {
/**
* Indicates that this is an external user. Mainly used for SSO users with a key connector.
*/
val isExternal: Boolean
get() = authenticationMethodsReference.any { it == "external" }
}

View File

@@ -0,0 +1,15 @@
package com.x8bit.bitwarden.data.auth.repository.model
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
/**
* Helper function to map a [VaultUnlockError] to a [LoginResult.Error] with
* the necessary `message` if applicable.
*/
fun VaultUnlockError.toLoginErrorResult(): LoginResult.Error = when (this) {
is VaultUnlockResult.AuthenticationError -> LoginResult.Error(this.message)
VaultUnlockResult.GenericError,
VaultUnlockResult.InvalidStateError,
-> LoginResult.Error(errorMessage = null)
}

View File

@@ -1,12 +1,21 @@
package com.x8bit.bitwarden.data.auth.repository.model
import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType
/**
* Represents an organization a user may be a member of.
*
* @property id The ID of the organization.
* @property name The name of the organization (if applicable).
* @property shouldManageResetPassword Indicates that this user has the permission to manage their
* own password.
* @property shouldUseKeyConnector Indicates that the organization uses a key connector.
* @property role The user's role in the organization.
*/
data class Organization(
val id: String,
val name: String?,
val shouldManageResetPassword: Boolean,
val shouldUseKeyConnector: Boolean,
val role: OrganizationType,
)

View File

@@ -0,0 +1,16 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of removing a user's password.
*/
sealed class RemovePasswordResult {
/**
* The password was removed successfully.
*/
data object Success : RemovePasswordResult()
/**
* There was an error removing the password.
*/
data object Error : RemovePasswordResult()
}

View File

@@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of sending a verification email.
*/
sealed class SendVerificationEmailResult {
/**
* Email sent succeeded.
*
* @param emailVerificationToken the token to verify the email.
*/
data class Success(
val emailVerificationToken: String?,
) : SendVerificationEmailResult()
/**
* There was an error sending the email.
*
* @param errorMessage a message describing the error.
*/
data class Error(val errorMessage: String?) : SendVerificationEmailResult()
}

View File

@@ -0,0 +1,9 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Associates [isUsingKeyConnector] with the given [userId].
*/
data class UserKeyConnectorState(
val userId: String,
val isUsingKeyConnector: Boolean?,
)

View File

@@ -45,10 +45,12 @@ data class UserState(
* they logged in using SSO and don't yet have one). NOTE: This should **not** be used to
* determine whether a user has a master password. There are cases in which a user can both
* not have a password but still not need one, such as TDE.
* @property hasMasterPassword Indicates that the user does or does not have a master password.
* @property organizations List of [Organization]s the user is associated with, if any.
* @property isBiometricsEnabled Indicates that the biometrics mechanism for unlocking the
* user's vault is enabled.
* @property vaultUnlockType The mechanism by which the user's vault may be unlocked.
* @property isUsingKeyConnector Indicates if the account is currently using a key connector.
*/
data class Account(
val userId: String,
@@ -61,16 +63,13 @@ data class UserState(
val isVaultUnlocked: Boolean,
val needsPasswordReset: Boolean,
val needsMasterPassword: Boolean,
val hasMasterPassword: Boolean,
val trustedDevice: TrustedDevice?,
val organizations: List<Organization>,
val isBiometricsEnabled: Boolean,
val vaultUnlockType: VaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
val isUsingKeyConnector: Boolean,
) {
/**
* Indicates that the user does or does not have a master password.
*/
val hasMasterPassword: Boolean get() = trustedDevice?.hasMasterPassword != false
/**
* Indicates that the user does or does not have a means to manually unlock the vault.
*/
@@ -86,7 +85,6 @@ data class UserState(
*/
data class TrustedDevice(
val isDeviceTrusted: Boolean,
val hasMasterPassword: Boolean,
val hasAdminApproval: Boolean,
val hasLoginApprovingDevice: Boolean,
val hasResetPasswordPermission: Boolean,

View File

@@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.auth.repository.model
/**
* Models result of determining if a PIN is valid.
*/
sealed class ValidatePinResult {
/**
* The validity of the PIN was checked successfully and [isValid].
*/
data class Success(
val isValid: Boolean,
) : ValidatePinResult()
/**
* There was an error determining if the validity of the PIN.
*/
data object Error : ValidatePinResult()
}

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.repository.util
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.UserSwitchingData
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -100,6 +101,47 @@ val AuthDiskSource.userAccountTokensFlow: Flow<List<UserAccountTokens>>
}
.distinctUntilChanged()
/**
* Returns the current list of [UserKeyConnectorState].
*/
val AuthDiskSource.userKeyConnectorStateList: List<UserKeyConnectorState>
get() = this
.userState
?.accounts
.orEmpty()
.map { (userId, _) ->
UserKeyConnectorState(
userId = userId,
isUsingKeyConnector = this.getShouldUseKeyConnector(userId = userId),
)
}
/**
* Returns a [Flow] that emits distinct updates to [UserKeyConnectorState].
*/
@OptIn(ExperimentalCoroutinesApi::class)
val AuthDiskSource.userKeyConnectorStateFlow: Flow<List<UserKeyConnectorState>>
get() = this
.userStateFlow
.flatMapLatest { userStateJson ->
combine(
userStateJson
?.accounts
.orEmpty()
.map { (userId, _) ->
this
.getShouldUseKeyConnectorFlow(userId = userId)
.map {
UserKeyConnectorState(
userId = userId,
isUsingKeyConnector = it,
)
}
},
) { it.toList() }
}
.distinctUntilChanged()
/**
* Returns a [Flow] that emits every time the active user is changed.
*/

View File

@@ -17,7 +17,7 @@ private val json: Json by lazy {
/**
* Parses a [JwtTokenDataJson] from the given [jwtToken], or `null` if this parsing is not possible.
*/
@Suppress("MagicNumber", "ReturnCount")
@Suppress("MagicNumber")
fun parseJwtTokenDataOrNull(jwtToken: String): JwtTokenDataJson? {
val parts = jwtToken.split(".")
if (parts.size != 3) return null

View File

@@ -1,39 +0,0 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.RefreshTokenResponseJson
/**
* Converts the given [RefreshTokenResponseJson] to a [UserStateJson], given the following
* additional information:
*
* - the [userId]
* - the [previousUserState]
*/
fun RefreshTokenResponseJson.toUserStateJson(
userId: String,
previousUserState: UserStateJson,
): UserStateJson {
val refreshedAccount = requireNotNull(previousUserState.accounts[userId])
val accessToken = this.accessToken
val jwtTokenData = requireNotNull(parseJwtTokenDataOrNull(jwtToken = accessToken))
val account = refreshedAccount.copy(
profile = refreshedAccount.profile.copy(
userId = jwtTokenData.userId,
email = jwtTokenData.email,
isEmailVerified = jwtTokenData.isEmailVerified,
name = jwtTokenData.name,
),
)
// Update the existing UserState.
return previousUserState.copy(
accounts = previousUserState
.accounts
.toMutableMap()
.apply {
put(userId, account)
},
)
}

View File

@@ -14,6 +14,9 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization =
Organization(
id = this.id,
name = this.name,
shouldUseKeyConnector = this.shouldUseKeyConnector,
role = this.type,
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
)
/**

View File

@@ -3,21 +3,48 @@ package com.x8bit.bitwarden.data.auth.repository.util
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.UserDecryptionOptionsJson
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrlsOrDefault
import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
import com.x8bit.bitwarden.ui.platform.base.util.toHexColorRepresentation
/**
* Updates the given [UserStateJson] with the data to indicate that the password has been removed.
* The original will be returned if the [userId] does not match any accounts in the [UserStateJson].
*/
fun UserStateJson.toRemovedPasswordUserStateJson(
userId: String,
): UserStateJson {
val account = this.accounts[userId] ?: return this
val profile = account.profile
val updatedUserDecryptionOptions = profile
.userDecryptionOptions
?.copy(hasMasterPassword = false)
?: UserDecryptionOptionsJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
)
val updatedProfile = profile.copy(userDecryptionOptions = updatedUserDecryptionOptions)
val updatedAccount = account.copy(profile = updatedProfile)
return this.copy(
accounts = accounts
.toMutableMap()
.apply { replace(userId, updatedAccount) },
)
}
/**
* Updates the given [UserStateJson] with the data from the [syncResponse] to return a new
* [UserStateJson]. The original will be returned if the sync response does not match any accounts
* in the [UserStateJson].
*/
@Suppress("ReturnCount")
fun UserStateJson.toUpdatedUserStateJson(
syncResponse: SyncResponseJson,
): UserStateJson {
@@ -75,11 +102,12 @@ fun UserStateJson.toUserStateJsonWithPassword(): UserStateJson {
/**
* Converts the given [UserStateJson] to a [UserState] using the given [vaultState].
*/
@Suppress("LongParameterList")
@Suppress("LongParameterList", "LongMethod")
fun UserStateJson.toUserState(
vaultState: List<VaultUnlockData>,
userAccountTokens: List<UserAccountTokens>,
userOrganizationsList: List<UserOrganizations>,
userIsUsingKeyConnectorList: List<UserKeyConnectorState>,
hasPendingAccountAddition: Boolean,
isBiometricsEnabledProvider: (userId: String) -> Boolean,
vaultUnlockTypeProvider: (userId: String) -> VaultUnlockType,
@@ -98,13 +126,21 @@ fun UserStateJson.toUserState(
val decryptionOptions = profile.userDecryptionOptions
val trustedDeviceOptions = decryptionOptions?.trustedDeviceUserDecryptionOptions
val keyConnectorOptions = decryptionOptions?.keyConnectorUserDecryptionOptions
val organizations = userOrganizationsList
.find { it.userId == userId }
?.organizations
.orEmpty()
val hasManageResetPasswordPermission = organizations.any {
it.role == OrganizationType.OWNER ||
it.role == OrganizationType.ADMIN ||
it.shouldManageResetPassword
}
val needsMasterPassword = decryptionOptions?.hasMasterPassword == false &&
trustedDeviceOptions?.hasManageResetPasswordPermission != false &&
hasManageResetPasswordPermission &&
keyConnectorOptions == null
val trustedDevice = trustedDeviceOptions?.let {
UserState.TrustedDevice(
isDeviceTrusted = isDeviceTrustedProvider(userId),
hasMasterPassword = decryptionOptions.hasMasterPassword,
hasAdminApproval = it.hasAdminApproval,
hasLoginApprovingDevice = it.hasLoginApprovingDevice,
hasResetPasswordPermission = it.hasManageResetPasswordPermission,
@@ -126,14 +162,15 @@ fun UserStateJson.toUserState(
?.isLoggedIn == true,
isVaultUnlocked = vaultUnlocked,
needsPasswordReset = needsPasswordReset,
organizations = userOrganizationsList
.find { it.userId == userId }
?.organizations
.orEmpty(),
organizations = organizations,
isBiometricsEnabled = isBiometricsEnabledProvider(userId),
vaultUnlockType = vaultUnlockTypeProvider(userId),
needsMasterPassword = needsMasterPassword,
hasMasterPassword = decryptionOptions?.hasMasterPassword != false,
trustedDevice = trustedDevice,
isUsingKeyConnector = userIsUsingKeyConnectorList
.find { it.userId == userId }
?.isUsingKeyConnector == true,
)
},
hasPendingAccountAddition = hasPendingAccountAddition,

View File

@@ -0,0 +1,28 @@
package com.x8bit.bitwarden.data.auth.util
import android.content.Intent
import android.net.Uri
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
/**
* Checks if the given [Intent] contains data to complete registration.
* The [CompleteRegistrationData] will be returned when present.
*/
fun Intent.getCompleteRegistrationDataIntentOrNull(): CompleteRegistrationData? {
val sanitizedUriString = data.toString().replace(
oldValue = "/redirect-connector.html#",
newValue = "/",
ignoreCase = true,
)
val uri = runCatching { Uri.parse(sanitizedUriString) }.getOrNull() ?: return null
uri.host ?: return null
if (uri.path != "/finish-signup") return null
val email = uri.getQueryParameter("email") ?: return null
val verificationToken = uri.getQueryParameter("token") ?: return null
val fromEmail = uri.getBooleanQueryParameter("fromEmail", true)
return CompleteRegistrationData(
email = email,
verificationToken = verificationToken,
fromEmail = fromEmail,
)
}

View File

@@ -10,6 +10,22 @@ import com.x8bit.bitwarden.data.autofill.model.FilledPartition
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider
import com.x8bit.bitwarden.data.autofill.util.buildFilledItemOrNull
/**
* The maximum amount of filled partitions the user will see. Viewing the rest will require opening
* the vault.
*
* Note: The vault item is not included in this count.
*/
private const val MAX_FILLED_PARTITIONS_COUNT: Int = 20
/**
* The maximum amount of inline suggestions the user will see. Viewing the rest will require
* opening the vault.
*
* Note: The vault item is not included in this count.
*/
private const val MAX_INLINE_SUGGESTION_COUNT: Int = 5
/**
* The default [FilledDataBuilder]. This converts parsed autofill data into filled data that is
* ready to be loaded into an autofill response.
@@ -21,7 +37,9 @@ class FilledDataBuilderImpl(
val isVaultLocked = autofillCipherProvider.isVaultLocked()
// Subtract one to make sure there is space for the vault item.
val maxCipherInlineSuggestionsCount = autofillRequest.maxInlineSuggestionsCount - 1
val maxCipherInlineSuggestionsCount = (autofillRequest.maxInlineSuggestionsCount - 1)
.coerceAtMost(maximumValue = MAX_INLINE_SUGGESTION_COUNT)
// Track the number of inline suggestions that have been added.
var inlineSuggestionsAdded = 0
@@ -76,7 +94,7 @@ class FilledDataBuilderImpl(
?.getOrLastOrNull(inlineSuggestionsAdded)
return FilledData(
filledPartitions = filledPartitions,
filledPartitions = filledPartitions.take(n = MAX_FILLED_PARTITIONS_COUNT),
ignoreAutofillIds = autofillRequest.ignoreAutofillIds,
originalPartition = autofillRequest.partition,
uri = autofillRequest.uri,

View File

@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.autofill.builder
import android.service.autofill.FillRequest
import android.service.autofill.SaveInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
/**
@@ -12,13 +11,11 @@ interface SaveInfoBuilder {
/**
* Build a save info out the provided data. If that isn't possible, return null.
*
* @param autofillAppInfo App data that is required for building the [SaveInfo].
* @param autofillPartition The portion of the processed [FillRequest] that will be filled.
* @param fillRequest The [FillRequest] that initiated the autofill flow.
* @param packageName The package name that was extracted from the [FillRequest].
*/
fun build(
autofillAppInfo: AutofillAppInfo,
autofillPartition: AutofillPartition,
fillRequest: FillRequest,
packageName: String?,

View File

@@ -1,10 +1,7 @@
package com.x8bit.bitwarden.data.autofill.builder
import android.annotation.SuppressLint
import android.os.Build
import android.service.autofill.FillRequest
import android.service.autofill.SaveInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillAppInfo
import com.x8bit.bitwarden.data.autofill.model.AutofillPartition
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@@ -16,9 +13,7 @@ class SaveInfoBuilderImpl(
val settingsRepository: SettingsRepository,
) : SaveInfoBuilder {
@SuppressLint("InlinedApi")
override fun build(
autofillAppInfo: AutofillAppInfo,
autofillPartition: AutofillPartition,
fillRequest: FillRequest,
packageName: String?,
@@ -29,12 +24,8 @@ class SaveInfoBuilderImpl(
// Docs state that password fields cannot be reliably saved
// in Compat mode since they show as masked values.
val isInCompatMode = if (autofillAppInfo.sdkInt >= Build.VERSION_CODES.Q) {
// Attempt to automatically establish compat request mode on Android 10+
(fillRequest.flags or FillRequest.FLAG_COMPATIBILITY_MODE_REQUEST) == fillRequest.flags
} else {
COMPAT_BROWSERS.contains(packageName)
}
val isInCompatMode = (fillRequest.flags or
FillRequest.FLAG_COMPATIBILITY_MODE_REQUEST) == fillRequest.flags
// If login and compat mode, the password might be obfuscated,
// in which case we should skip the save request.
@@ -58,103 +49,3 @@ class SaveInfoBuilderImpl(
}
}
}
/**
* These browsers function using the compatibility shim for the Autofill Framework.
*
* Ensure that these entries are sorted alphabetically and keep this list synchronized with the
* values in /xml/autofill_service_configuration.xml and
* /xml-v30/autofill_service_configuration.xml.
*/
private val COMPAT_BROWSERS: List<String> = listOf(
"alook.browser",
"alook.browser.google",
"app.vanadium.browser",
"com.amazon.cloud9",
"com.android.browser",
"com.android.chrome",
"com.android.htmlviewer",
"com.avast.android.secure.browser",
"com.avg.android.secure.browser",
"com.brave.browser",
"com.brave.browser_beta",
"com.brave.browser_default",
"com.brave.browser_dev",
"com.brave.browser_nightly",
"com.chrome.beta",
"com.chrome.canary",
"com.chrome.dev",
"com.cookiegames.smartcookie",
"com.cookiejarapps.android.smartcookieweb",
"com.ecosia.android",
"com.google.android.apps.chrome",
"com.google.android.apps.chrome_dev",
"com.google.android.captiveportallogin",
"com.iode.firefox",
"com.jamal2367.styx",
"com.kiwibrowser.browser",
"com.kiwibrowser.browser.dev",
"com.lemurbrowser.exts",
"com.microsoft.emmx",
"com.microsoft.emmx.beta",
"com.microsoft.emmx.canary",
"com.microsoft.emmx.dev",
"com.mmbox.browser",
"com.mmbox.xbrowser",
"com.mycompany.app.soulbrowser",
"com.naver.whale",
"com.neeva.app",
"com.opera.browser",
"com.opera.browser.beta",
"com.opera.gx",
"com.opera.mini.native",
"com.opera.mini.native.beta",
"com.opera.touch",
"com.qflair.browserq",
"com.qwant.liberty",
"com.rainsee.create",
"com.sec.android.app.sbrowser",
"com.sec.android.app.sbrowser.beta",
"com.stoutner.privacybrowser.free",
"com.stoutner.privacybrowser.standard",
"com.vivaldi.browser",
"com.vivaldi.browser.snapshot",
"com.vivaldi.browser.sopranos",
"com.yandex.browser",
"com.yjllq.internet",
"com.yjllq.kito",
"com.yujian.ResideMenuDemo",
"com.z28j.feel",
"idm.internet.download.manager",
"idm.internet.download.manager.adm.lite",
"idm.internet.download.manager.plus",
"io.github.forkmaintainers.iceraven",
"mark.via",
"mark.via.gp",
"net.dezor.browser",
"net.slions.fulguris.full.download",
"net.slions.fulguris.full.download.debug",
"net.slions.fulguris.full.playstore",
"net.slions.fulguris.full.playstore.debug",
"org.adblockplus.browser",
"org.adblockplus.browser.beta",
"org.bromite.bromite",
"org.bromite.chromium",
"org.chromium.chrome",
"org.codeaurora.swe.browser",
"org.cromite.cromite",
"org.gnu.icecat",
"org.mozilla.fenix",
"org.mozilla.fenix.nightly",
"org.mozilla.fennec_aurora",
"org.mozilla.fennec_fdroid",
"org.mozilla.firefox",
"org.mozilla.firefox_beta",
"org.mozilla.reference.browser",
"org.mozilla.rocket",
"org.torproject.torbrowser",
"org.torproject.torbrowser_alpha",
"org.ungoogled.chromium.extensions.stable",
"org.ungoogled.chromium.stable",
"us.spotco.fennec_dos",
)

View File

@@ -19,6 +19,7 @@ import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessor
import com.x8bit.bitwarden.data.autofill.processor.AutofillProcessorImpl
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProvider
import com.x8bit.bitwarden.data.autofill.provider.AutofillCipherProviderImpl
import com.x8bit.bitwarden.data.platform.manager.CrashLogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
@@ -72,6 +73,7 @@ object AutofillModule {
organizationEventManager = organizationEventManager,
)
@Singleton
@Provides
fun providesAutofillParser(
settingsRepository: SettingsRepository,
@@ -80,6 +82,7 @@ object AutofillModule {
settingsRepository = settingsRepository,
)
@Singleton
@Provides
fun providesAutofillCipherProvider(
authRepository: AuthRepository,
@@ -92,6 +95,7 @@ object AutofillModule {
vaultRepository = vaultRepository,
)
@Singleton
@Provides
fun providesAutofillProcessor(
dispatcherManager: DispatcherManager,
@@ -101,6 +105,7 @@ object AutofillModule {
policyManager: PolicyManager,
saveInfoBuilder: SaveInfoBuilder,
settingsRepository: SettingsRepository,
crashLogsManager: CrashLogsManager,
): AutofillProcessor =
AutofillProcessorImpl(
dispatcherManager = dispatcherManager,
@@ -110,8 +115,10 @@ object AutofillModule {
policyManager = policyManager,
saveInfoBuilder = saveInfoBuilder,
settingsRepository = settingsRepository,
crashLogsManager = crashLogsManager,
)
@Singleton
@Provides
fun providesFillDataBuilder(
autofillCipherProvider: AutofillCipherProvider,
@@ -119,6 +126,7 @@ object AutofillModule {
autofillCipherProvider = autofillCipherProvider,
)
@Singleton
@Provides
fun providesFillResponseBuilder(): FillResponseBuilder = FillResponseBuilderImpl()

View File

@@ -24,10 +24,7 @@ object Fido2NetworkModule {
): DigitalAssetLinkService =
DigitalAssetLinkServiceImpl(
digitalAssetLinkApi = retrofits
.staticRetrofitBuilder
// This URL will be overridden dynamically.
.baseUrl("https://www.bitwarden.com")
.build()
.createStaticRetrofit()
.create(),
)
}

View File

@@ -3,15 +3,17 @@ package com.x8bit.bitwarden.data.autofill.fido2.di
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import com.bitwarden.sdk.Fido2CredentialStore
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.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import dagger.Module
import dagger.Provides
@@ -19,12 +21,12 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import java.time.Clock
import javax.inject.Singleton
/**
* Provides dependencies within the fido2 package.
*/
@OmitFromCoverage
@Module
@InstallIn(SingletonComponent::class)
object Fido2ProviderModule {
@@ -35,13 +37,21 @@ object Fido2ProviderModule {
fun provideCredentialProviderProcessor(
@ApplicationContext context: Context,
authRepository: AuthRepository,
vaultRepository: VaultRepository,
fido2CredentialStore: Fido2CredentialStore,
fido2CredentialManager: Fido2CredentialManager,
dispatcherManager: DispatcherManager,
intentManager: IntentManager,
clock: Clock,
): Fido2ProviderProcessor =
Fido2ProviderProcessorImpl(
context,
authRepository,
vaultRepository,
fido2CredentialStore,
fido2CredentialManager,
intentManager,
clock,
dispatcherManager,
)
@@ -50,11 +60,15 @@ object Fido2ProviderModule {
fun provideFido2CredentialManager(
assetManager: AssetManager,
digitalAssetLinkService: DigitalAssetLinkService,
vaultSdkSource: VaultSdkSource,
fido2CredentialStore: Fido2CredentialStore,
json: Json,
): Fido2CredentialManager =
Fido2CredentialManagerImpl(
assetManager = assetManager,
digitalAssetLinkService = digitalAssetLinkService,
vaultSdkSource = vaultSdkSource,
fido2CredentialStore = fido2CredentialStore,
json = json,
)
}

View File

@@ -1,15 +1,29 @@
package com.x8bit.bitwarden.data.autofill.fido2.manager
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model.PublicKeyCredentialCreationOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CreateCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
/**
* Responsible for managing FIDO 2 credential creation and authentication.
* Responsible for managing FIDO 2 credential registration and authentication.
*/
interface Fido2CredentialManager {
/**
* Returns true when the user has performed an explicit verification action. E.g., biometric
* verification, device credential verification, or vault unlock.
*/
var isUserVerified: Boolean
/**
* The number of times the user has attempted to authenticate with their password or PIN
* for the FIDO 2 user verification flow.
*/
var authenticationAttempts: Int
/**
* Attempt to validate the RP and origin of the provided [fido2CredentialRequest].
@@ -19,18 +33,39 @@ interface Fido2CredentialManager {
): Fido2ValidateOriginResult
/**
* Attempt to extract FIDO 2 passkey creation options from the system [requestJson], or null.
* Attempt to extract FIDO 2 passkey attestation options from the system [requestJson], or null.
*/
fun getPasskeyCreateOptionsOrNull(
fun getPasskeyAttestationOptionsOrNull(
requestJson: String,
): PublicKeyCredentialCreationOptions?
): PasskeyAttestationOptions?
/**
* Attempt to create a FIDO2 credential from the given [credentialRequest] and associate it to
* the given [cipherView].
* Attempt to extract FIDO 2 passkey assertion options from the system [requestJson], or null.
*/
fun createCredentialForCipher(
credentialRequest: Fido2CredentialRequest,
cipherView: CipherView,
): Fido2CreateCredentialResult
fun getPasskeyAssertionOptionsOrNull(
requestJson: String,
): PasskeyAssertionOptions?
/**
* Register a new FIDO 2 credential to a users vault.
*/
suspend fun registerFido2Credential(
userId: String,
fido2CredentialRequest: Fido2CredentialRequest,
selectedCipherView: CipherView,
): Fido2RegisterCredentialResult
/**
* Authenticate a FIDO credential against a cipher in the users vault.
*/
suspend fun authenticateFido2Credential(
userId: String,
request: Fido2CredentialAssertionRequest,
selectedCipherView: CipherView,
): Fido2CredentialAssertionResult
/**
* Whether or not the user has authentication attempts remaining.
*/
fun hasAuthenticationAttemptsRemaining(): Boolean
}

View File

@@ -1,21 +1,33 @@
package com.x8bit.bitwarden.data.autofill.fido2.manager
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.provider.CallingAppInfo
import com.bitwarden.fido.ClientData
import com.bitwarden.sdk.Fido2CredentialStore
import com.bitwarden.vault.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.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2RegisterCredentialResult
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2ValidateOriginResult
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAssertionOptions
import com.x8bit.bitwarden.data.autofill.fido2.model.PasskeyAttestationOptions
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.getAppOrigin
import com.x8bit.bitwarden.data.platform.util.getAppSigningSignatureFingerprint
import com.x8bit.bitwarden.data.platform.util.getSignatureFingerprintAsHexString
import com.x8bit.bitwarden.data.platform.util.validatePrivilegedApp
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.AuthenticateFido2CredentialRequest
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.RegisterFido2CredentialRequest
import com.x8bit.bitwarden.data.vault.datasource.sdk.util.toAndroidAttestationResponse
import com.x8bit.bitwarden.data.vault.datasource.sdk.util.toAndroidFido2PublicKeyCredential
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private const val ALLOW_LIST_FILE_NAME = "fido2_privileged_allow_list.json"
@@ -23,11 +35,64 @@ private const val ALLOW_LIST_FILE_NAME = "fido2_privileged_allow_list.json"
/**
* Primary implementation of [Fido2CredentialManager].
*/
@Suppress("TooManyFunctions")
class Fido2CredentialManagerImpl(
private val assetManager: AssetManager,
private val digitalAssetLinkService: DigitalAssetLinkService,
private val vaultSdkSource: VaultSdkSource,
private val fido2CredentialStore: Fido2CredentialStore,
private val json: Json,
) : Fido2CredentialManager {
) : Fido2CredentialManager,
Fido2CredentialStore by fido2CredentialStore {
override var isUserVerified: Boolean = false
override var authenticationAttempts: Int = 0
override suspend fun registerFido2Credential(
userId: String,
fido2CredentialRequest: Fido2CredentialRequest,
selectedCipherView: CipherView,
): Fido2RegisterCredentialResult {
val clientData = if (fido2CredentialRequest.callingAppInfo.isOriginPopulated()) {
fido2CredentialRequest
.callingAppInfo
.getAppSigningSignatureFingerprint()
?.let { ClientData.DefaultWithCustomHash(hash = it) }
?: return Fido2RegisterCredentialResult.Error
} else {
ClientData.DefaultWithExtraData(
androidPackageName = fido2CredentialRequest
.callingAppInfo
.packageName,
)
}
val origin = fido2CredentialRequest
.origin
?: getOriginUrlFromAttestationOptionsOrNull(fido2CredentialRequest.requestJson)
?: return Fido2RegisterCredentialResult.Error
return vaultSdkSource
.registerFido2Credential(
request = RegisterFido2CredentialRequest(
userId = userId,
origin = origin,
requestJson = """{"publicKey": ${fido2CredentialRequest.requestJson}}""",
clientData = clientData,
selectedCipherView = selectedCipherView,
// User verification is handled prior to engaging the SDK. We always respond
// `true` so that the SDK does not fail if the relying party requests UV.
isUserVerificationSupported = true,
),
fido2CredentialStore = this,
)
.map { it.toAndroidAttestationResponse() }
.mapCatching { json.encodeToString(it) }
.fold(
onSuccess = { Fido2RegisterCredentialResult.Success(it) },
onFailure = { Fido2RegisterCredentialResult.Error },
)
}
override suspend fun validateOrigin(
fido2CredentialRequest: Fido2CredentialRequest,
@@ -40,18 +105,61 @@ class Fido2CredentialManagerImpl(
}
}
override fun getPasskeyCreateOptionsOrNull(
override fun getPasskeyAttestationOptionsOrNull(
requestJson: String,
): PublicKeyCredentialCreationOptions? =
): PasskeyAttestationOptions? =
try {
json.decodeFromString<PublicKeyCredentialCreationOptions>(requestJson)
json.decodeFromString<PasskeyAttestationOptions>(requestJson)
} catch (e: SerializationException) {
null
} catch (e: IllegalArgumentException) {
null
}
@Suppress("ReturnCount")
override fun getPasskeyAssertionOptionsOrNull(
requestJson: String,
): PasskeyAssertionOptions? =
try {
json.decodeFromString<PasskeyAssertionOptions>(requestJson)
} catch (e: SerializationException) {
null
} catch (e: IllegalArgumentException) {
null
}
override suspend fun authenticateFido2Credential(
userId: String,
request: Fido2CredentialAssertionRequest,
selectedCipherView: CipherView,
): Fido2CredentialAssertionResult {
val callingAppInfo = request.callingAppInfo
val clientData = request.clientDataHash
?.let { ClientData.DefaultWithCustomHash(hash = it) }
?: ClientData.DefaultWithExtraData(androidPackageName = callingAppInfo.getAppOrigin())
val origin = request.origin
?: getOriginUrlFromAssertionOptionsOrNull(request.requestJson)
?: return Fido2CredentialAssertionResult.Error
return vaultSdkSource
.authenticateFido2Credential(
request = AuthenticateFido2CredentialRequest(
userId = userId,
origin = origin,
requestJson = """{"publicKey": ${request.requestJson}}""",
clientData = clientData,
selectedCipherView = selectedCipherView,
isUserVerificationSupported = true,
),
fido2CredentialStore = this,
)
.map { it.toAndroidFido2PublicKeyCredential() }
.mapCatching { json.encodeToString(it) }
.fold(
onSuccess = { Fido2CredentialAssertionResult.Success(it) },
onFailure = { Fido2CredentialAssertionResult.Error },
)
}
private suspend fun validateCallingApplicationAssetLinks(
fido2CredentialRequest: Fido2CredentialRequest,
): Fido2ValidateOriginResult {
@@ -73,10 +181,13 @@ class Fido2CredentialManagerImpl(
?: return Fido2ValidateOriginResult.Error.ApplicationNotFound
}
.map { matchingStatements ->
matchingStatements
.filterMatchingAppSignaturesOrNull(
signature = callingAppInfo.getCallingAppApkFingerprint(),
)
callingAppInfo.getSignatureFingerprintAsHexString()
?.let { certificateFingerprint ->
matchingStatements
.filterMatchingAppSignaturesOrNull(
signature = certificateFingerprint,
)
}
?: return Fido2ValidateOriginResult.Error.ApplicationNotVerified
}
.fold(
@@ -104,14 +215,6 @@ class Fido2CredentialManagerImpl(
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.
*/
@@ -147,7 +250,7 @@ class Fido2CredentialManagerImpl(
private fun String.getRpId(json: Json): Result<String> {
return try {
json
.decodeFromString<PublicKeyCredentialCreationOptions>(this)
.decodeFromString<PasskeyAttestationOptions>(this)
.relyingParty
.id
.asSuccess()
@@ -157,4 +260,21 @@ class Fido2CredentialManagerImpl(
e.asFailure()
}
}
override fun hasAuthenticationAttemptsRemaining(): Boolean =
authenticationAttempts < MAX_AUTHENTICATION_ATTEMPTS
private fun getOriginUrlFromAssertionOptionsOrNull(requestJson: String) =
getPasskeyAssertionOptionsOrNull(requestJson)
?.relyingPartyId
?.let { "$HTTPS$it" }
private fun getOriginUrlFromAttestationOptionsOrNull(requestJson: String) =
getPasskeyAttestationOptionsOrNull(requestJson)
?.relyingParty
?.id
?.let { "$HTTPS$it" }
}
private const val MAX_AUTHENTICATION_ATTEMPTS = 5
private const val HTTPS = "https://"

View File

@@ -0,0 +1,64 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Represents the authenticator's response to a clients request for the creation of a new public
* key credential.
*
* Refer to https://w3c.github.io/webauthn/#iface-authenticatorattestationresponse for details.
*/
@Serializable
data class Fido2AttestationResponse(
@SerialName("id")
val id: String,
@SerialName("type")
val type: String,
@SerialName("rawId")
val rawId: String,
@SerialName("response")
val response: RegistrationResponse,
@SerialName("clientExtensionResults")
val clientExtensionResults: ClientExtensionResults?,
@SerialName("authenticatorAttachment")
val authenticatorAttachment: String?,
) {
/**
* Represents the registration result data expected from a FIDO2 credential registration
* request.
*/
@Serializable
data class RegistrationResponse(
@SerialName("clientDataJSON")
val clientDataJson: String,
@SerialName("attestationObject")
val attestationObject: String,
@SerialName("transports")
val transports: List<String>?,
@SerialName("publicKeyAlgorithm")
val publicKeyAlgorithm: Long,
@SerialName("publicKey")
val publicKey: String?,
@SerialName("authenticatorData")
val authenticatorData: String?,
)
/**
* Represents an extension processing result produced by the client.
*/
@Serializable
data class ClientExtensionResults(
@SerialName("credProps")
val credentialProperties: CredentialProperties,
) {
/**
* Represents properties for newly created credential.
*/
@Serializable
data class CredentialProperties(
@SerialName("rk")
val residentKey: Boolean,
)
}
}

View File

@@ -1,23 +0,0 @@
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()
}

View File

@@ -0,0 +1,23 @@
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
/**
* Models a FIDO 2 credential authentication request parsed from the launching intent.
*/
@Parcelize
data class Fido2CredentialAssertionRequest(
val cipherId: String?,
val credentialId: String?,
val requestJson: String,
val clientDataHash: ByteArray?,
val packageName: String,
val signingInfo: SigningInfo,
val origin: String?,
) : Parcelable {
val callingAppInfo: CallingAppInfo
get() = CallingAppInfo(packageName, signingInfo, origin)
}

View File

@@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
/**
* Represents possible outcomes of a FIDO 2 credential assertion request.
*/
sealed class Fido2CredentialAssertionResult {
/**
* Indicates the assertion request completed and [responseJson] was successfully generated.
*/
data class Success(val responseJson: String) : Fido2CredentialAssertionResult()
/**
* Indicates there was an error and the assertion was not successful.
*/
data object Error : Fido2CredentialAssertionResult()
}

View File

@@ -0,0 +1,33 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import android.content.pm.SigningInfo
import android.os.Bundle
import android.os.Parcelable
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import androidx.credentials.provider.CallingAppInfo
import kotlinx.parcelize.Parcelize
/**
* Models a FIDO 2 request to retrieve FIDO credentials parsed from the launching intent.
*/
@Parcelize
data class Fido2GetCredentialsRequest(
val candidateQueryData: Bundle,
val id: String,
val requestJson: String,
val clientDataHash: ByteArray? = null,
val packageName: String,
val signingInfo: SigningInfo,
val origin: String?,
) : Parcelable {
val callingAppInfo: CallingAppInfo
get() = CallingAppInfo(packageName, signingInfo, origin)
val option: BeginGetPublicKeyCredentialOption
get() = BeginGetPublicKeyCredentialOption(
candidateQueryData,
id,
requestJson,
clientDataHash,
)
}

View File

@@ -0,0 +1,26 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import com.bitwarden.fido.Fido2CredentialAutofillView
/**
* Represents the result of a FIDO 2 Get Credentials request.
*/
sealed class Fido2GetCredentialsResult {
/**
* Indicates credentials were successfully queried.
*
* @param options Original request options provided by the relying party.
* @param credentials Collection of [Fido2CredentialAutofillView]s matching the original request
* parameters. This may be an empty list if no matching values were found.
*/
data class Success(
val options: BeginGetPublicKeyCredentialOption,
val credentials: List<Fido2CredentialAutofillView>,
) : Fido2GetCredentialsResult()
/**
* Indicates an error was encountered when querying for matching credentials.
*/
data object Error : Fido2GetCredentialsResult()
}

View File

@@ -0,0 +1,57 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models a FIDO 2 public key credential.
*/
@Serializable
data class Fido2PublicKeyCredential(
@SerialName("id")
val id: String,
@SerialName("rawId")
val rawId: String,
@SerialName("type")
val type: String,
@SerialName("authenticatorAttachment")
val authenticatorAttachment: String?,
@SerialName("response")
val response: Fido2AssertionResponse,
@SerialName("clientExtensionResults")
val clientExtensionResults: ClientExtensionResults,
) {
/**
* Models a FIDO 2 public key assertion response.
*/
@Serializable
data class Fido2AssertionResponse(
@SerialName("clientDataJSON")
val clientDataJson: String?,
@SerialName("authenticatorData")
val authenticatorData: String,
@SerialName("signature")
val signature: String,
@SerialName("userHandle")
val userHandle: String?,
)
/**
* Models FIDO 2 credential properties provided by a client.
*/
@Serializable
data class ClientExtensionResults(
@SerialName("credProps")
val credentialProperties: CredentialProperties?,
) {
/**
* Models the FIDO 2 credential properties provided by a client.
*/
@Serializable
data class CredentialProperties(
@SerialName("rk")
val residentKey: Boolean?,
)
}
}

View File

@@ -0,0 +1,24 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
/**
* Models the data returned from creating a FIDO 2 credential.
*/
sealed class Fido2RegisterCredentialResult {
/**
* Indicates the credential has been successfully registered.
*/
data class Success(
val registrationResponse: String,
) : Fido2RegisterCredentialResult()
/**
* Indicates there was an error and the credential was not registered.
*/
data object Error : Fido2RegisterCredentialResult()
/**
* Indicates the user cancelled the request.
*/
data object Cancelled : Fido2RegisterCredentialResult()
}

View File

@@ -0,0 +1,20 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models the request options for a passkey request, based off the spec found at:
* https://www.w3.org/TR/webauthn-2/#dictionary-assertion-options
*/
@Serializable
data class PasskeyAssertionOptions(
@SerialName("challenge")
val challenge: String,
@SerialName("allowCredentials")
val allowCredentials: List<PublicKeyCredentialDescriptor>?,
@SerialName("rpId")
val relyingPartyId: String?,
@SerialName("userVerification")
val userVerification: UserVerificationRequirement = UserVerificationRequirement.PREFERRED,
)

View File

@@ -1,13 +1,13 @@
package com.x8bit.bitwarden.data.autofill.fido2.datasource.network.model
package com.x8bit.bitwarden.data.autofill.fido2.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Models a FIDO 2 credential creation request options received from a Relying Party (RP).
* Models FIDO 2 credential creation request options received from a Relying Party (RP).
*/
@Serializable
data class PublicKeyCredentialCreationOptions(
data class PasskeyAttestationOptions(
@SerialName("authenticatorSelection")
val authenticatorSelection: AuthenticatorSelectionCriteria,
@SerialName("challenge")
@@ -30,7 +30,9 @@ data class PublicKeyCredentialCreationOptions(
@SerialName("authenticatorAttachment")
val authenticatorAttachment: AuthenticatorAttachment? = null,
@SerialName("residentKey")
val residentKey: ResidentKeyRequirement? = null,
val residentKeyRequirement: ResidentKeyRequirement? = null,
@SerialName("userVerification")
val userVerification: UserVerificationRequirement = UserVerificationRequirement.PREFERRED,
) {
/**
* Enum class representing the types of attachments associated with selection criteria.
@@ -50,32 +52,19 @@ data class PublicKeyCredentialCreationOptions(
@Serializable
enum class ResidentKeyRequirement {
/**
* User verification is preferred during selection, if supported.
* Resident keys are preferred during selection, if supported.
*/
@SerialName("preferred")
PREFERRED,
/**
* User verification is required during selection.
* Resident keys are 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<String>,
)
/**
* Represents parameters for a credential in the creation options.
*/

View File

@@ -0,0 +1,17 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* 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<String>,
)

View File

@@ -0,0 +1,29 @@
package com.x8bit.bitwarden.data.autofill.fido2.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Enum class indicating the type of user verification requested by the relying party.
*/
@Serializable
enum class UserVerificationRequirement {
/**
* User verification should not be performed.
*/
@SerialName("discouraged")
DISCOURAGED,
/**
* User verification is preferred, if supported by the device or application.
*/
@SerialName("preferred")
PREFERRED,
/**
* User verification is required. If is cannot be performed the registration process
* should be terminated.
*/
@SerialName("required")
REQUIRED,
}

View File

@@ -10,36 +10,55 @@ import androidx.credentials.exceptions.ClearCredentialUnsupportedException
import androidx.credentials.exceptions.CreateCredentialCancellationException
import androidx.credentials.exceptions.CreateCredentialException
import androidx.credentials.exceptions.CreateCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialCancellationException
import androidx.credentials.exceptions.GetCredentialException
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.exceptions.GetCredentialUnsupportedException
import androidx.credentials.provider.AuthenticationAction
import androidx.credentials.provider.BeginCreateCredentialRequest
import androidx.credentials.provider.BeginCreateCredentialResponse
import androidx.credentials.provider.BeginCreatePublicKeyCredentialRequest
import androidx.credentials.provider.BeginGetCredentialRequest
import androidx.credentials.provider.BeginGetCredentialResponse
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import androidx.credentials.provider.CreateEntry
import androidx.credentials.provider.CredentialEntry
import androidx.credentials.provider.ProviderClearCredentialStateRequest
import androidx.credentials.provider.PublicKeyCredentialEntry
import com.bitwarden.fido.Fido2CredentialAutofillView
import com.bitwarden.sdk.Fido2CredentialStore
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.autofill.fido2.manager.Fido2CredentialManager
import com.x8bit.bitwarden.data.autofill.util.isActiveWithFido2Credentials
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAutofillViewResult
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.time.Clock
import java.util.concurrent.atomic.AtomicInteger
private const val CREATE_PASSKEY_INTENT = "com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY"
const val GET_PASSKEY_INTENT = "com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY"
const val UNLOCK_ACCOUNT_INTENT = "com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT"
/**
* The default implementation of [Fido2ProviderProcessor]. Its purpose is to handle FIDO2 related
* processing.
*/
@Suppress("LongParameterList")
@RequiresApi(Build.VERSION_CODES.S)
class Fido2ProviderProcessorImpl(
private val context: Context,
private val authRepository: AuthRepository,
private val vaultRepository: VaultRepository,
private val fido2CredentialStore: Fido2CredentialStore,
private val fido2CredentialManager: Fido2CredentialManager,
private val intentManager: IntentManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
) : Fido2ProviderProcessor {
@@ -51,22 +70,23 @@ class Fido2ProviderProcessorImpl(
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginCreateCredentialResponse, CreateCredentialException>,
) {
cancellationSignal.setOnCancelListener {
callback.onError(CreateCredentialCancellationException())
scope.cancel()
}
val userId = authRepository.activeUserId
if (userId == null) {
callback.onError(CreateCredentialUnknownException("Active user is required."))
return
}
scope.launch {
val createCredentialJob = scope.launch {
processCreateCredentialRequest(request = request)
?.let { callback.onResult(it) }
?: callback.onError(CreateCredentialUnknownException())
}
cancellationSignal.setOnCancelListener {
if (createCredentialJob.isActive) {
createCredentialJob.cancel()
}
callback.onError(CreateCredentialCancellationException())
}
}
private fun processCreateCredentialRequest(
@@ -81,7 +101,6 @@ class Fido2ProviderProcessorImpl(
}
}
@Suppress("ReturnCount")
private fun handleCreatePasskeyQuery(
request: BeginCreatePublicKeyCredentialRequest,
): BeginCreateCredentialResponse? {
@@ -94,13 +113,14 @@ class Fido2ProviderProcessorImpl(
val userState = authRepository.userStateFlow.value ?: return null
return BeginCreateCredentialResponse.Builder()
.setCreateEntries(userState.accounts.toCreateEntries())
.setCreateEntries(userState.accounts.toCreateEntries(userState.activeUserId))
.build()
}
private fun List<UserState.Account>.toCreateEntries() = map { it.toCreateEntry() }
private fun List<UserState.Account>.toCreateEntries(activeUserId: String) =
map { it.toCreateEntry(isActive = activeUserId == it.userId) }
private fun UserState.Account.toCreateEntry(): CreateEntry {
private fun UserState.Account.toCreateEntry(isActive: Boolean): CreateEntry {
val accountName = name ?: email
return CreateEntry
.Builder(
@@ -117,6 +137,9 @@ class Fido2ProviderProcessorImpl(
accountName,
),
)
// Set the last used time to "now" so the active account is the default option in the
// system prompt.
.setLastUsedTime(if (isActive) clock.instant() else null)
.build()
}
@@ -125,10 +148,120 @@ class Fido2ProviderProcessorImpl(
cancellationSignal: CancellationSignal,
callback: OutcomeReceiver<BeginGetCredentialResponse, GetCredentialException>,
) {
// no-op: RFU
callback.onError(GetCredentialUnsupportedException())
// If the user is not logged in, return an error.
val userState = authRepository.userStateFlow.value
if (userState == null) {
callback.onError(GetCredentialUnknownException("Active user is required."))
return
}
// Return an unlock action if the current account is locked.
if (!userState.activeAccount.isVaultUnlocked) {
val authenticationAction = AuthenticationAction(
title = context.getString(R.string.unlock),
pendingIntent = intentManager.createFido2UnlockPendingIntent(
action = UNLOCK_ACCOUNT_INTENT,
requestCode = requestCode.getAndIncrement(),
),
)
callback.onResult(
BeginGetCredentialResponse(
authenticationActions = listOf(authenticationAction),
),
)
return
}
// Otherwise, find all matching credentials from the current vault.
val getCredentialJob = scope.launch {
try {
val credentialEntries = getMatchingFido2CredentialEntries(
userId = userState.activeUserId,
request = request,
)
callback.onResult(
BeginGetCredentialResponse(
credentialEntries = credentialEntries,
),
)
} catch (e: GetCredentialException) {
callback.onError(e)
}
}
cancellationSignal.setOnCancelListener {
callback.onError(GetCredentialCancellationException())
getCredentialJob.cancel()
}
}
@Throws(GetCredentialUnsupportedException::class)
private suspend fun getMatchingFido2CredentialEntries(
userId: String,
request: BeginGetCredentialRequest,
): List<CredentialEntry> =
request
.beginGetCredentialOptions
.flatMap { option ->
if (option is BeginGetPublicKeyCredentialOption) {
val relyingPartyId = fido2CredentialManager
.getPasskeyAssertionOptionsOrNull(requestJson = option.requestJson)
?.relyingPartyId
?: throw GetCredentialUnknownException("Invalid data.")
buildCredentialEntries(relyingPartyId, option)
} else {
throw GetCredentialUnsupportedException("Unsupported option.")
}
}
private suspend fun buildCredentialEntries(
relyingPartyId: String,
option: BeginGetPublicKeyCredentialOption,
): List<CredentialEntry> {
val cipherViews = vaultRepository
.ciphersStateFlow
.value
.data
?.filter { it.isActiveWithFido2Credentials }
?: emptyList()
val result = vaultRepository
.getDecryptedFido2CredentialAutofillViews(cipherViews)
return when (result) {
DecryptFido2CredentialAutofillViewResult.Error -> {
throw GetCredentialUnknownException("Error decrypting credentials.")
}
is DecryptFido2CredentialAutofillViewResult.Success -> {
result
.fido2CredentialAutofillViews
.filter { it.rpId == relyingPartyId }
.toCredentialEntries(option)
}
}
}
private fun List<Fido2CredentialAutofillView>.toCredentialEntries(
option: BeginGetPublicKeyCredentialOption,
): List<CredentialEntry> =
this
.map {
PublicKeyCredentialEntry
.Builder(
context = context,
username = it.userNameForUi ?: context.getString(R.string.no_username),
pendingIntent = intentManager
.createFido2GetCredentialPendingIntent(
action = GET_PASSKEY_INTENT,
credentialId = it.credentialId.toString(),
cipherId = it.cipherId,
requestCode = requestCode.getAndIncrement(),
),
beginGetPublicKeyCredentialOption = option,
)
.build()
}
override fun processClearCredentialStateRequest(
request: ProviderClearCredentialStateRequest,
cancellationSignal: CancellationSignal,

View File

@@ -3,27 +3,32 @@ package com.x8bit.bitwarden.data.autofill.fido2.util
import android.content.Intent
import android.os.Build
import androidx.credentials.CreatePublicKeyCredentialRequest
import androidx.credentials.GetPublicKeyCredentialOption
import androidx.credentials.provider.BeginGetPublicKeyCredentialOption
import androidx.credentials.provider.PendingIntentHandler
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_CIPHER_ID
import com.x8bit.bitwarden.ui.platform.manager.intent.EXTRA_KEY_CREDENTIAL_ID
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)
val systemRequest = PendingIntentHandler
.retrieveProviderCreateCredentialRequest(this)
?: return null
val createPublicKeyRequest =
systemRequest.callingRequest as? CreatePublicKeyCredentialRequest
?: return null
val createPublicKeyRequest = systemRequest
.callingRequest
as? CreatePublicKeyCredentialRequest
?: return null
val userId = getStringExtra(EXTRA_KEY_USER_ID)
?: return null
@@ -36,3 +41,67 @@ fun Intent.getFido2CredentialRequestOrNull(): Fido2CredentialRequest? {
origin = systemRequest.callingAppInfo.origin,
)
}
/**
* Checks if this [Intent] contains a [Fido2CredentialAssertionRequest] related to an ongoing FIDO 2
* credential authentication process.
*/
fun Intent.getFido2AssertionRequestOrNull(): Fido2CredentialAssertionRequest? {
if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) return null
val systemRequest = PendingIntentHandler
.retrieveProviderGetCredentialRequest(this)
?: return null
val option: GetPublicKeyCredentialOption = systemRequest
.credentialOptions
.firstNotNullOfOrNull { it as? GetPublicKeyCredentialOption }
?: return null
val credentialId = getStringExtra(EXTRA_KEY_CREDENTIAL_ID)
?: return null
val cipherId = getStringExtra(EXTRA_KEY_CIPHER_ID)
?: return null
return Fido2CredentialAssertionRequest(
cipherId = cipherId,
credentialId = credentialId,
requestJson = option.requestJson,
clientDataHash = option.clientDataHash,
packageName = systemRequest.callingAppInfo.packageName,
signingInfo = systemRequest.callingAppInfo.signingInfo,
origin = systemRequest.callingAppInfo.origin,
)
}
/**
* Checks if this [Intent] contains a [Fido2GetCredentialsRequest] related to an ongoing FIDO 2
* credential lookup process.
*/
fun Intent.getFido2GetCredentialsRequestOrNull(): Fido2GetCredentialsRequest? {
if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) return null
val systemRequest = PendingIntentHandler
.retrieveBeginGetCredentialRequest(this)
?: return null
val option: BeginGetPublicKeyCredentialOption = systemRequest
.beginGetCredentialOptions
.firstNotNullOfOrNull { it as? BeginGetPublicKeyCredentialOption }
?: return null
val callingAppInfo = systemRequest
.callingAppInfo
?: return null
return Fido2GetCredentialsRequest(
candidateQueryData = option.candidateQueryData,
id = option.id,
requestJson = option.requestJson,
clientDataHash = option.clientDataHash,
packageName = callingAppInfo.packageName,
signingInfo = callingAppInfo.signingInfo,
origin = callingAppInfo.origin,
)
}

View File

@@ -107,10 +107,11 @@ class AutofillCompletionManagerImpl(
cipherView: CipherView,
) {
val isPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true
val totpAvailableViaPremiumOrOrganization = isPremium || cipherView.organizationUseTotp
val totpCode = cipherView.login?.totp
val isTotpDisabled = settingsRepository.isAutoCopyTotpDisabled
if (!isTotpDisabled && isPremium && totpCode != null) {
if (!isTotpDisabled && totpAvailableViaPremiumOrOrganization && totpCode != null) {
val totpResult = vaultRepository.generateTotp(
time = DateTime.now(),
totpCode = totpCode,

View File

@@ -15,6 +15,7 @@ sealed class AutofillView {
* @param autofillType The autofill field type. (ex: View.AUTOFILL_TYPE_TEXT)
* @param isFocused Whether the view is currently focused.
* @param textValue A text value that represents the input present in the field.
* @param hasPasswordTerms Indicates that the field includes password terms.
*/
data class Data(
val autofillId: AutofillId,
@@ -22,6 +23,7 @@ sealed class AutofillView {
val autofillType: Int,
val isFocused: Boolean,
val textValue: String?,
val hasPasswordTerms: Boolean,
)
/**
@@ -86,4 +88,11 @@ sealed class AutofillView {
override val data: Data,
) : Login()
}
/**
* A view that is an input field but does not correspond to any known autofill field.
*/
data class Unused(
override val data: Data,
) : AutofillView()
}

View File

@@ -64,6 +64,7 @@ class AutofillParserImpl(
/**
* Parse the [AssistStructure] into an [AutofillRequest].
*/
@Suppress("LongMethod")
private fun parseInternal(
assistStructure: AssistStructure,
autofillAppInfo: AutofillAppInfo,
@@ -71,13 +72,24 @@ class AutofillParserImpl(
): AutofillRequest {
// Parse the `assistStructure` into internal models.
val traversalDataList = assistStructure.traverse()
// Flatten the autofill views for processing.
val autofillViews = traversalDataList
.map { it.autofillViews }
// Take only the autofill views from the node that currently has focus.
// Then remove all the fields that cannot be filled with data.
// We fallback to taking all the fillable views if nothing has focus.
val autofillViewsList = traversalDataList.map { it.autofillViews }
val autofillViews = autofillViewsList
.filter { views -> views.any { it.data.isFocused } }
.flatten()
.filter { it !is AutofillView.Unused }
.takeUnless { it.isEmpty() }
?: autofillViewsList
.flatten()
.filter { it !is AutofillView.Unused }
// Find the focused view.
val focusedView = autofillViews.firstOrNull { it.data.isFocused }
// Find the focused view, or fallback to the first fillable item on the screen (so
// we at least have something to hook into)
val focusedView = autofillViews
.firstOrNull { it.data.isFocused }
?: autofillViews.firstOrNull()
val packageName = traversalDataList.buildPackageNameOrNull(
assistStructure = assistStructure,
@@ -105,6 +117,12 @@ class AutofillParserImpl(
views = autofillViews.filterIsInstance<AutofillView.Login>(),
)
}
is AutofillView.Unused -> {
// The view is unfillable since the field is not meant to be used for autofill.
// This will never happen since we filter out all unused views above.
return AutofillRequest.Unfillable
}
}
// Flatten the ignorable autofill ids.
val ignoreAutofillIds = traversalDataList
@@ -139,7 +157,82 @@ class AutofillParserImpl(
private fun AssistStructure.traverse(): List<ViewNodeTraversalData> =
(0 until windowNodeCount)
.map { getWindowNodeAt(it) }
.mapNotNull { windowNode -> windowNode.rootViewNode?.traverse() }
.mapNotNull { windowNode ->
windowNode
.rootViewNode
?.traverse()
?.updateForMissingPasswordFields()
?.updateForMissingUsernameFields()
}
/**
* This helper function updates the [ViewNodeTraversalData] if necessary for missing password
* fields that were marked invalid because they contained a specific `hint` or `idEntry`. If the
* current `ViewNodeTraversalData` contains at least one password fields, we do not add any fields.
*/
private fun ViewNodeTraversalData.updateForMissingPasswordFields(): ViewNodeTraversalData =
if (this.autofillViews.none { it is AutofillView.Login.Password }) {
this.copyAndMapAutofillViews { _, autofillView ->
if (autofillView is AutofillView.Unused && autofillView.data.hasPasswordTerms) {
AutofillView.Login.Password(data = autofillView.data)
} else {
autofillView
}
}
} else {
// We already have password fields available, so no need to add more.
this
}
/**
* This helper function updates the [ViewNodeTraversalData] if necessary for missing username
* fields that could have been missed. If the current `ViewNodeTraversalData` contains password
* fields but no username fields, we check to see if there are any unused fields directly above
* the password fields and we assume that those are the missing username fields.
*/
private fun ViewNodeTraversalData.updateForMissingUsernameFields(): ViewNodeTraversalData {
val passwordPositions = this.autofillViews.mapIndexedNotNull { index, autofillView ->
(autofillView as? AutofillView.Login.Password)?.let { index }
}
return if (passwordPositions.any() &&
this.autofillViews.none { it is AutofillView.Login.Username }
) {
this.copyAndMapAutofillViews { index, autofillView ->
if (autofillView is AutofillView.Unused && passwordPositions.contains(index + 1)) {
AutofillView.Login.Username(data = autofillView.data)
} else {
autofillView
}
}
} else {
// We already have username fields available or there are no password fields, so no need
// to search for them.
this
}
}
/**
* This helper function loops through all the [ViewNodeTraversalData.autofillViews] and returns the
* fully updated `ViewNodeTraversalData`.
*/
private fun ViewNodeTraversalData.copyAndMapAutofillViews(
mapper: (index: Int, autofillView: AutofillView) -> AutofillView,
): ViewNodeTraversalData {
val updatedAutofillViews = autofillViews.mapIndexed(mapper)
val previousUnusedIds = autofillViews
.filterIsInstance<AutofillView.Unused>()
.map { it.data.autofillId }
.toSet()
val currentUnusedIds = updatedAutofillViews
.filterIsInstance<AutofillView.Unused>()
.map { it.data.autofillId }
.toSet()
val unignoredAutofillIds = previousUnusedIds - currentUnusedIds
return this.copy(
autofillViews = updatedAutofillViews,
ignoreAutofillIds = this.ignoreAutofillIds - unignoredAutofillIds,
)
}
/**
* Recursively traverse this [AssistStructure.ViewNode] and all of its descendants. Convert the

View File

@@ -13,12 +13,13 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillRequest
import com.x8bit.bitwarden.data.autofill.parser.AutofillParser
import com.x8bit.bitwarden.data.autofill.util.createAutofillSavedItemIntentSender
import com.x8bit.bitwarden.data.autofill.util.toAutofillSaveItem
import com.x8bit.bitwarden.data.platform.manager.CrashLogsManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
/**
@@ -34,6 +35,7 @@ class AutofillProcessorImpl(
private val parser: AutofillParser,
private val saveInfoBuilder: SaveInfoBuilder,
private val settingsRepository: SettingsRepository,
private val crashLogsManager: CrashLogsManager,
) : AutofillProcessor {
/**
@@ -41,6 +43,11 @@ class AutofillProcessorImpl(
*/
private val scope: CoroutineScope = CoroutineScope(dispatcherManager.unconfined)
/**
* The job being used to process the fill request.
*/
private var job: Job = Job().apply { complete() }
override fun processFillRequest(
autofillAppInfo: AutofillAppInfo,
cancellationSignal: CancellationSignal,
@@ -48,13 +55,16 @@ class AutofillProcessorImpl(
request: FillRequest,
) {
// Set the listener so that any long running work is cancelled when it is no longer needed.
cancellationSignal.setOnCancelListener { scope.cancel() }
cancellationSignal.setOnCancelListener { job.cancel() }
// Process the OS data and handle invoking the callback with the result.
process(
autofillAppInfo = autofillAppInfo,
fillCallback = fillCallback,
fillRequest = request,
)
job.cancel()
job = scope.launch {
process(
autofillAppInfo = autofillAppInfo,
fillCallback = fillCallback,
fillRequest = request,
)
}
}
override fun processSaveRequest(
@@ -101,7 +111,7 @@ class AutofillProcessorImpl(
/**
* Process the [fillRequest] and invoke the [FillCallback] with the response.
*/
private fun process(
private suspend fun process(
autofillAppInfo: AutofillAppInfo,
fillCallback: FillCallback,
fillRequest: FillRequest,
@@ -113,26 +123,30 @@ class AutofillProcessorImpl(
)
when (autofillRequest) {
is AutofillRequest.Fillable -> {
scope.launch {
// Fulfill the [autofillRequest].
val filledData = filledDataBuilder.build(
autofillRequest = autofillRequest,
)
val saveInfo = saveInfoBuilder.build(
autofillAppInfo = autofillAppInfo,
autofillPartition = autofillRequest.partition,
fillRequest = fillRequest,
packageName = autofillRequest.packageName,
)
// Fulfill the [autofillRequest].
val filledData = filledDataBuilder.build(
autofillRequest = autofillRequest,
)
val saveInfo = saveInfoBuilder.build(
autofillPartition = autofillRequest.partition,
fillRequest = fillRequest,
packageName = autofillRequest.packageName,
)
// Load the filledData and saveInfo into a FillResponse.
val response = fillResponseBuilder.build(
autofillAppInfo = autofillAppInfo,
filledData = filledData,
saveInfo = saveInfo,
)
// Load the filledData and saveInfo into a FillResponse.
val response = fillResponseBuilder.build(
autofillAppInfo = autofillAppInfo,
filledData = filledData,
saveInfo = saveInfo,
)
@Suppress("TooGenericExceptionCaught")
try {
fillCallback.onSuccess(response)
} catch (e: RuntimeException) {
// This is to catch any TransactionTooLargeExceptions that could occur here.
// These exceptions get wrapped as a RuntimeException.
crashLogsManager.trackNonFatalException(e)
}
}

View File

@@ -6,11 +6,22 @@ import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.model.AutofillCipher
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
import com.x8bit.bitwarden.data.platform.util.firstWithTimeoutOrNull
import com.x8bit.bitwarden.data.platform.util.subtitle
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockData
import com.x8bit.bitwarden.data.vault.repository.util.statusFor
import kotlinx.coroutines.flow.first
/**
* The duration, in milliseconds, we should wait while waiting for the vault status to not be
* 'UNLOCKING' before proceeding.
*/
private const val VAULT_LOCKED_TIMEOUT_MS: Long = 500L
/**
* The duration, in milliseconds, we should wait while retrieving ciphers before proceeding.
*/
private const val GET_CIPHERS_TIMEOUT_MS: Long = 2_000L
/**
* The default [AutofillCipherProvider] implementation. This service is used for getting current
@@ -28,9 +39,11 @@ class AutofillCipherProviderImpl(
// Wait for any unlocking actions to finish. This can be relevant on startup for Never lock
// accounts.
vaultRepository.vaultUnlockDataStateFlow.first {
it.statusFor(userId) != VaultUnlockData.Status.UNLOCKING
}
vaultRepository
.vaultUnlockDataStateFlow
.firstWithTimeoutOrNull(timeMillis = VAULT_LOCKED_TIMEOUT_MS) {
it.statusFor(userId = userId) != VaultUnlockData.Status.UNLOCKING
}
return !vaultRepository.isVaultUnlocked(userId = userId)
}
@@ -105,6 +118,6 @@ class AutofillCipherProviderImpl(
vaultRepository
.ciphersStateFlow
.takeUnless { isVaultLocked() }
?.first { it.data != null }
?.firstWithTimeoutOrNull(timeMillis = GET_CIPHERS_TIMEOUT_MS) { it.data != null }
?.data
}

View File

@@ -18,6 +18,7 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.autofill.model.AutofillTotpCopyData
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.util.getSafeParcelableExtra
import kotlin.random.Random
private const val AUTOFILL_SAVE_ITEM_DATA_KEY = "autofill-save-item-data"
private const val AUTOFILL_SELECTION_DATA_KEY = "autofill-selection-data"
@@ -57,19 +58,16 @@ fun createTotpCopyIntentSender(
context,
AutofillTotpCopyActivity::class.java,
)
.apply {
putExtra(
AUTOFILL_BUNDLE_KEY,
bundleOf(
AUTOFILL_TOTP_COPY_DATA_KEY to AutofillTotpCopyData(cipherId = cipherId),
),
)
}
.putExtra(
AUTOFILL_BUNDLE_KEY,
bundleOf(
AUTOFILL_TOTP_COPY_DATA_KEY to AutofillTotpCopyData(cipherId = cipherId),
),
)
return PendingIntent
.getActivity(
context,
0,
Random.nextInt(),
intent,
PendingIntent.FLAG_CANCEL_CURRENT.toPendingIntentMutabilityFlag(),
)
@@ -95,7 +93,7 @@ fun createAutofillSavedItemIntentSender(
return PendingIntent
.getActivity(
autofillAppInfo.context,
0,
Random.nextInt(),
intent,
PendingIntent.FLAG_CANCEL_CURRENT.toPendingIntentMutabilityFlag(),
)

View File

@@ -26,7 +26,12 @@ fun AutofillValue.extractMonthValue(
/**
* Extract a text value from this [AutofillValue].
*/
fun AutofillValue.extractTextValue(): String? = this
.textValue
.takeIf { it.isNotBlank() }
?.toString()
fun AutofillValue.extractTextValue(): String? =
if (this.isText) {
this
.textValue
.takeIf { it.isNotBlank() }
?.toString()
} else {
null
}

View File

@@ -44,3 +44,9 @@ fun CipherView.toAutofillCipherProvider(): AutofillCipherProvider =
)
}
}
/**
* Returns true when the cipher is not deleted and contains at least one FIDO 2 credential.
*/
val CipherView.isActiveWithFido2Credentials: Boolean
get() = deletedDate == null && !(login?.fido2Credentials.isNullOrEmpty())

View File

@@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.autofill.model.FilledData
import com.x8bit.bitwarden.data.autofill.model.FilledItem
import com.x8bit.bitwarden.ui.autofill.buildVaultItemAutofillRemoteViews
import com.x8bit.bitwarden.ui.autofill.util.createVaultItemInlinePresentationOrNull
import kotlin.random.Random
/**
* Returns all the possible [AutofillId]s that were potentially fillable for the given [FilledData].
@@ -43,7 +44,7 @@ fun FilledData.buildVaultItemDataset(
val pendingIntent = PendingIntent
.getActivity(
autofillAppInfo.context,
0,
Random.nextInt(),
intent,
PendingIntent.FLAG_CANCEL_CURRENT.toPendingIntentMutabilityFlag(),
)

View File

@@ -2,14 +2,10 @@ package com.x8bit.bitwarden.data.autofill.util
import android.app.assist.AssistStructure
import android.view.View
import android.widget.EditText
import com.x8bit.bitwarden.data.autofill.model.AutofillView
import com.x8bit.bitwarden.ui.platform.base.util.orNullIfBlank
/**
* The class name of the android edit text field.
*/
private const val ANDROID_EDIT_TEXT_CLASS_NAME: String = "android.widget.EditText"
/**
* The default web URI scheme.
*/
@@ -59,7 +55,18 @@ private val SUPPORTED_VIEW_HINTS: List<String> = listOf(
* Whether this [AssistStructure.ViewNode] represents an input field.
*/
private val AssistStructure.ViewNode.isInputField: Boolean
get() = className == ANDROID_EDIT_TEXT_CLASS_NAME || htmlInfo.isInputField
get() {
val isEditText = className
?.let {
try {
Class.forName(it)
} catch (e: ClassNotFoundException) {
null
}
}
?.let { EditText::class.java.isAssignableFrom(it) } == true
return isEditText || htmlInfo.isInputField
}
/**
* Attempt to convert this [AssistStructure.ViewNode] into an [AutofillView]. If the view node
@@ -87,6 +94,7 @@ fun AssistStructure.ViewNode.toAutofillView(): AutofillView? =
autofillType = this.autofillType,
isFocused = this.isFocused,
textValue = this.autofillValue?.extractTextValue(),
hasPasswordTerms = this.hasPasswordTerms(),
)
buildAutofillView(
autofillOptions = autofillOptions,
@@ -105,7 +113,7 @@ private fun AssistStructure.ViewNode.buildAutofillView(
autofillOptions: List<String>,
autofillViewData: AutofillView.Data,
supportedHint: String?,
): AutofillView? = when {
): AutofillView = when {
supportedHint == View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_MONTH -> {
val monthValue = this
.autofillValue
@@ -149,20 +157,21 @@ private fun AssistStructure.ViewNode.buildAutofillView(
)
}
else -> null
else -> {
AutofillView.Unused(
data = autofillViewData,
)
}
}
/**
* Check whether this [AssistStructure.ViewNode] represents a password field.
*/
@Suppress("ReturnCount")
fun AssistStructure.ViewNode.isPasswordField(
supportedHint: String?,
): Boolean {
if (supportedHint == View.AUTOFILL_HINT_PASSWORD) return true
if (this.hint?.containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) == true) return true
val isInvalidField = this.idEntry?.containsAnyTerms(IGNORED_RAW_HINTS) == true ||
this.hint?.containsAnyTerms(IGNORED_RAW_HINTS) == true
val isUsernameField = this.isUsernameField(supportedHint)
@@ -173,6 +182,13 @@ fun AssistStructure.ViewNode.isPasswordField(
.isPasswordField()
}
/**
* Check whether this [AssistStructure.ViewNode] includes any password specific terms.
*/
fun AssistStructure.ViewNode.hasPasswordTerms(): Boolean =
this.idEntry?.containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) == true ||
this.hint?.containsAnyTerms(SUPPORTED_RAW_PASSWORD_HINTS) == true
/**
* Check whether this [AssistStructure.ViewNode] represents a username field.
*/

View File

@@ -13,7 +13,6 @@ private const val ANDROID_APP_SCHEME: String = "androidapp"
* Try and build a URI. First, try building a website from the list of [ViewNodeTraversalData]. If
* that fails, try converting [packageName] into an Android app URI.
*/
@Suppress("ReturnCount")
fun List<ViewNodeTraversalData>.buildUriOrNull(
packageName: String?,
): String? {

Some files were not shown because too many files have changed in this diff Show More