Compare commits

..

139 Commits

Author SHA1 Message Date
André Bispo
1d05f5f758 [PM-6702] Add email verification feature flag to landing create account click 2024-08-16 14:46:31 +01: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
394 changed files with 23450 additions and 3693 deletions

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@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -67,7 +68,7 @@ jobs:
java-version: ${{ env.JAVA_VERSION }}
- name: Configure Ruby
uses: ruby/setup-ruby@3a77c29278ae80936b4cb030fefc7d21c96c786f # v1.185.0
uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0
with:
bundler-cache: true
@@ -98,7 +99,7 @@ jobs:
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Configure Ruby
uses: ruby/setup-ruby@3a77c29278ae80936b4cb030fefc7d21c96c786f # v1.185.0
uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0
with:
bundler-cache: true
@@ -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@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -236,7 +237,7 @@ jobs:
- name: Upload release Play Store .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
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@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
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@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
with:
name: com.x8bit.bitwarden.apk
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden-standard-release.apk
@@ -260,7 +261,7 @@ jobs:
- name: Upload beta .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
with:
name: com.x8bit.bitwarden.beta.apk
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden-standard-beta.apk
@@ -269,7 +270,7 @@ jobs:
# When building variants other than 'prod'
- name: Upload other .apk artifact
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
with:
name: com.x8bit.bitwarden-${{ matrix.variant }}.apk
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden-standard-debug.apk
@@ -307,7 +308,7 @@ jobs:
- name: Upload .apk SHA file for release
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
with:
name: bw-android-apk-sha256.txt
path: ./bw-android-apk-sha256.txt
@@ -315,7 +316,7 @@ jobs:
- name: Upload .apk SHA file for beta
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
with:
name: bw-android-beta-apk-sha256.txt
path: ./bw-android-beta-apk-sha256.txt
@@ -323,7 +324,7 @@ jobs:
- name: Upload .aab SHA file for release
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
with:
name: bw-android-aab-sha256.txt
path: ./bw-android-aab-sha256.txt
@@ -331,7 +332,7 @@ jobs:
- name: Upload .aab SHA file for beta
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
with:
name: bw-android-beta-aab-sha256.txt
path: ./bw-android-beta-aab-sha256.txt
@@ -339,7 +340,7 @@ jobs:
- name: Upload .apk SHA file for other
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
with:
name: bw-android-${{ matrix.variant }}-apk-sha256.txt
path: ./bw-android-${{ matrix.variant }}-apk-sha256.txt
@@ -355,6 +356,7 @@ jobs:
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
@@ -363,6 +365,7 @@ jobs:
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@3a77c29278ae80936b4cb030fefc7d21c96c786f # v1.185.0
uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0
with:
bundler-cache: true
@@ -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@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -479,7 +482,7 @@ jobs:
keyPassword:"${{ env.FDROID_BETA_KEY_PASSWORD }}"
- name: Upload F-Droid .apk artifact
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
with:
name: com.x8bit.bitwarden-fdroid.apk
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid-release.apk
@@ -491,14 +494,14 @@ jobs:
> ./bw-fdroid-apk-sha256.txt
- name: Upload F-Droid SHA file
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
with:
name: bw-fdroid-apk-sha256.txt
path: ./bw-fdroid-apk-sha256.txt
if-no-files-found: error
- name: Upload F-Droid Beta .apk artifact
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
with:
name: com.x8bit.bitwarden-fdroid-beta.apk
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden-fdroid-beta.apk
@@ -510,7 +513,7 @@ jobs:
> ./bw-fdroid-beta-apk-sha256.txt
- name: Upload F-Droid Beta SHA file
uses: actions/upload-artifact@0b2256b8c012f0828dc542b3febcab082c67f72b # v4.3.4
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
with:
name: bw-fdroid-beta-apk-sha256.txt
path: ./bw-fdroid-beta-apk-sha256.txt
@@ -526,4 +529,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@61ac8b980551f674046220c3e104bddae2916ac5 # v2.0.0
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@61ac8b980551f674046220c3e104bddae2916ac5 # v2.0.0
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@6c56658230f79c227a55120e9b24845d574d5225 # 2.0.31
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@b611370bb5703a7efb587f9d136a52ea24c5c38c # v3.25.11
uses: github/codeql-action/upload-sarif@afb54ba388a7dca6ecae48f608c4ff05ff4cc77a # v3.25.15
with:
sarif_file: cx_result.sarif

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@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -58,7 +58,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure Ruby
uses: ruby/setup-ruby@3a77c29278ae80936b4cb030fefc7d21c96c786f # v1.185.0
uses: ruby/setup-ruby@a6e6f86333f0a2523ece813039b8b4be04560854 # v1.190.0
with:
bundler-cache: true

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.961.0)
aws-sdk-core (3.201.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
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.157.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)
@@ -162,7 +162,7 @@ GEM
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,7 +172,7 @@ 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)

View File

@@ -11,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

@@ -65,8 +65,13 @@ android {
// 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"
)
}
release {
isDebuggable = false

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

@@ -55,6 +55,23 @@
<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="bitwarden.com" />
<data android:host="bitwarden.pw" />
<data android:host="bitwarden.eu" />
</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 +158,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"

View File

@@ -6,9 +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
@@ -32,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"
@@ -51,6 +55,7 @@ class MainViewModel @Inject constructor(
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,
@@ -110,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)
}
@@ -170,6 +179,7 @@ class MainViewModel @Inject constructor(
)
}
@Suppress("LongMethod")
private fun handleIntent(
intent: Intent,
isFirstIntent: Boolean,
@@ -181,6 +191,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 =
@@ -192,6 +205,17 @@ class MainViewModel @Inject constructor(
)
}
completeRegistrationData != null -> {
if (authRepository.activeUserId != null) {
authRepository.hasPendingAccountAddition = true
}
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.CompleteRegistration(
completeRegistrationData = completeRegistrationData,
timestamp = clock.millis(),
)
}
autofillSaveItem != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.AutofillSave(
@@ -237,6 +261,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

View File

@@ -45,12 +45,22 @@ interface AuthDiskSource {
*/
fun clearData(userId: String)
/**
* Retrieves the state indicating that the user should use a key connector.
*/
fun getShouldUseKeyConnector(userId: String): 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 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 +70,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,7 @@ 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 USES_KEY_CONNECTOR = "usesKeyConnector"
/**
* Primary implementation of [AuthDiskSource].
@@ -111,7 +111,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 +123,31 @@ class AuthDiskSourceImpl(
storeMasterPasswordHash(userId = userId, passwordHash = null)
storePolicies(userId = userId, policies = null)
storeAccountTokens(userId = userId, accountTokens = null)
storeShouldUseKeyConnector(userId = userId, shouldUseKeyConnector = 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 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,
)
}
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))

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.auth.datasource.network.api
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.KeyConnectorKeyRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.VerifyOtpRequestJson
@@ -13,6 +14,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.
*/
@@ -45,6 +53,12 @@ interface AuthenticatedAccountsApi {
@HTTP(method = "POST", path = "/accounts/password", hasBody = true)
suspend fun resetPassword(@Body body: ResetPasswordRequestJson): Result<Unit>
/**
* Sets the key connector key.
*/
@POST("/accounts/set-key-connector-key")
suspend fun setKeyConnectorKey(@Body body: KeyConnectorKeyRequestJson): Result<Unit>
/**
* Sets the password.
*/

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
@@ -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

@@ -73,10 +73,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(),
)

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,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

@@ -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,47 @@
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 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)
*/
@Serializable
data class Invalid(
@SerialName("message")
val message: String?,
@SerialName("validationErrors")
val validationErrors: Map<String, List<String>>?,
) : SendVerificationEmailResponseJson()
/**
* A different error with a message.
*/
@Serializable
data class Error(
@SerialName("Message")
val message: String?,
) : SendVerificationEmailResponseJson()
}

View File

@@ -1,6 +1,7 @@
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.PasswordHintResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResetPasswordRequestJson
@@ -11,6 +12,11 @@ import com.x8bit.bitwarden.data.auth.datasource.network.model.SetPasswordRequest
*/
interface AccountsService {
/**
* Converts the currently active account to a key-connector account.
*/
suspend fun convertToKeyConnector(): Result<Unit>
/**
* Creates a new account's keys.
*/
@@ -49,6 +55,11 @@ interface AccountsService {
*/
suspend fun resetPassword(body: ResetPasswordRequestJson): Result<Unit>
/**
* Set the key connector key.
*/
suspend fun setKeyConnectorKey(body: KeyConnectorKeyRequestJson): Result<Unit>
/**
* Set the password.
*/

View File

@@ -5,6 +5,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.api.AuthenticatedAccount
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.PasswordHintRequestJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.PasswordHintResponseJson
import com.x8bit.bitwarden.data.auth.datasource.network.model.ResendEmailRequestJson
@@ -21,6 +22,12 @@ class AccountsServiceImpl(
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,
@@ -93,6 +100,10 @@ class AccountsServiceImpl(
}
}
override suspend fun setKeyConnectorKey(
body: KeyConnectorKeyRequestJson,
): Result<Unit> = authenticatedAccountsApi.setKeyConnectorKey(body)
override suspend fun setPassword(
body: SetPasswordRequestJson,
): Result<Unit> = authenticatedAccountsApi.setPassword(body)

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

@@ -7,8 +7,10 @@ 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 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
@@ -32,16 +34,21 @@ class IdentityServiceImpl(
.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,
)
?: bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.Error>(
code = 429,
json = json,
)
?: throw throwable
}
@Suppress("MagicNumber")
@@ -101,4 +108,32 @@ class IdentityServiceImpl(
refreshToken = refreshToken,
)
.executeForResult()
@Suppress("MagicNumber")
override suspend fun registerFinish(
body: RegisterFinishRequestJson,
): Result<RegisterResponseJson> =
api
.registerFinish(body)
.recoverCatching { throwable ->
val bitwardenError = throwable.toBitwardenError()
bitwardenError
.parseErrorBodyOrNull<RegisterResponseJson.Invalid>(
codes = listOf(400, 429),
json = json,
)
?: bitwardenError.parseErrorBodyOrNull<RegisterResponseJson.Error>(
code = 429,
json = json,
)
?: throw throwable
}
override suspend fun sendVerificationEmail(
body: SendVerificationEmailRequestJson,
): Result<String?> {
return api
.sendVerificationEmail(body = body)
.map { it?.content }
}
}

View File

@@ -7,11 +7,11 @@ 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 +19,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 +36,7 @@ class AuthSdkSourceImpl(
override suspend fun getUserFingerprint(
email: String,
publicKey: String,
): Result<String> = runCatching {
): Result<String> = runCatchingWithLogs {
getClient()
.platform()
.fingerprint(
@@ -51,7 +52,7 @@ class AuthSdkSourceImpl(
password: String,
kdf: Kdf,
purpose: HashPurpose,
): Result<String> = runCatching {
): Result<String> = runCatchingWithLogs {
getClient()
.auth()
.hashPassword(
@@ -66,7 +67,7 @@ class AuthSdkSourceImpl(
email: String,
password: String,
kdf: Kdf,
): Result<RegisterKeyResponse> = runCatching {
): Result<RegisterKeyResponse> = runCatchingWithLogs {
getClient()
.auth()
.makeRegisterKeys(
@@ -81,7 +82,7 @@ class AuthSdkSourceImpl(
email: String,
orgPublicKey: String,
rememberDevice: Boolean,
): Result<RegisterTdeKeyResponse> = runCatching {
): Result<RegisterTdeKeyResponse> = runCatchingWithLogs {
getClient(userId = userId)
.auth()
.makeRegisterTdeKeys(
@@ -95,7 +96,7 @@ class AuthSdkSourceImpl(
email: String,
password: String,
additionalInputs: List<String>,
): Result<PasswordStrength> = runCatching {
): Result<PasswordStrength> = runCatchingWithLogs {
@Suppress("UnsafeCallOnNullableType")
getClient()
.auth()
@@ -111,7 +112,7 @@ class AuthSdkSourceImpl(
password: String,
passwordStrength: PasswordStrength,
policy: MasterPasswordPolicyOptions,
): Result<Boolean> = runCatching {
): Result<Boolean> = runCatchingWithLogs {
getClient()
.auth()
.satisfiesPolicy(
@@ -120,8 +121,4 @@ class AuthSdkSourceImpl(
policy = policy,
)
}
private suspend fun getClient(
userId: String? = null,
): Client = sdkClientManager.getOrCreateClient(userId = userId)
}

View File

@@ -17,7 +17,7 @@ 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) {
false.asSuccess()
} else {
vaultSdkSource

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

@@ -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

@@ -19,10 +19,12 @@ import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
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
@@ -129,6 +131,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 +246,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 +254,7 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
email: String,
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String? = null,
captchaToken: String?,
shouldCheckDataBreaches: Boolean,
isMasterPasswordStrong: Boolean,
@@ -342,9 +346,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.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
@@ -49,14 +52,17 @@ import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
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.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
@@ -74,9 +80,11 @@ 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
@@ -87,8 +95,10 @@ import com.x8bit.bitwarden.data.platform.util.flatMap
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
@@ -135,9 +145,9 @@ class AuthRepositoryImpl(
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 {
/**
@@ -314,6 +324,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
@@ -456,7 +470,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 ->
@@ -508,18 +523,21 @@ 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),
)
checkForVaultUnlockError(
onVaultUnlockError = { error ->
return error.toLoginErrorResult()
},
) {
unlockVault(
accountProfile = profile,
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = requestPrivateKey,
method = AuthRequestMethod.UserKey(protectedUserKey = asymmetricalKey),
),
)
}
authDiskSource.storeUserKey(userId = userId, userKey = asymmetricalKey)
vaultRepository.syncIfNecessary()
@@ -704,19 +722,12 @@ class AuthRepositoryImpl(
return SwitchAccountResult.AccountSwitched
}
override fun updateLastActiveTime() {
val userId = activeUserId ?: return
authDiskSource.storeLastActiveTimeMillis(
userId = userId,
lastActiveTimeMillis = elapsedRealtimeMillisProvider(),
)
}
@Suppress("LongMethod")
override suspend fun register(
email: String,
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String?,
captchaToken: String?,
shouldCheckDataBreaches: Boolean,
isMasterPasswordStrong: Boolean,
@@ -745,21 +756,40 @@ 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) {
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 = {
@@ -954,9 +984,9 @@ class AuthRepositoryImpl(
)
}
VaultUnlockResult.AuthenticationError,
VaultUnlockResult.GenericError,
is VaultUnlockResult.AuthenticationError,
VaultUnlockResult.InvalidStateError,
VaultUnlockResult.GenericError,
-> {
IllegalStateException("Failed to unlock vault").asFailure()
}
@@ -1103,11 +1133,78 @@ 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) }
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,
@@ -1317,6 +1414,52 @@ class AuthRepositoryImpl(
environmentUrlData = environmentRepository.environment.environmentUrlData,
)
val userId = userStateJson.activeUserId
checkForVaultUnlockError(
onVaultUnlockError = { vaultUnlockError ->
return@userStateTransaction vaultUnlockError.toLoginErrorResult()
},
) {
val isDeviceUnlockAvailable = deviceData != null ||
loginResponse.userDecryptionOptions?.trustedDeviceUserDecryptionOptions != null
// if possible attempt to unlock the vault with trusted device data
if (isDeviceUnlockAvailable) {
unlockVaultWithTdeOnLoginSuccess(
loginResponse = loginResponse,
userStateJson = userStateJson,
deviceData = deviceData,
)
} else {
password?.let {
unlockVaultWithPasswordOnLoginSuccess(
loginResponse = loginResponse,
userStateJson = userStateJson,
password = it,
)
}
}
}
password?.let {
// 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)
}
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
@@ -1345,161 +1488,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.
*/
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.
*/
@@ -1525,6 +1524,182 @@ class AuthRepositoryImpl(
return LoginResult.TwoFactorRequired
}
/**
* Attempt to unlock the current user's vault with password data.
*/
private suspend fun unlockVaultWithPasswordOnLoginSuccess(
loginResponse: GetTokenResponseJson.Success,
userStateJson: UserStateJson,
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 = userStateJson.activeAccount.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,
userStateJson: UserStateJson,
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 = userStateJson.activeAccount.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,
userStateJson = userStateJson,
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,
userStateJson: UserStateJson,
privateKey: String,
): VaultUnlockResult? {
var vaultUnlockResult: VaultUnlockResult? = null
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 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 = userStateJson.activeAccount.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 = userStateJson.activeAccount.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

@@ -12,6 +12,7 @@ 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
@@ -52,6 +53,7 @@ object AuthRepositoryModule {
userLogoutManager: UserLogoutManager,
pushManager: PushManager,
policyManager: PolicyManager,
featureFlagManager: FeatureFlagManager,
): AuthRepository = AuthRepositoryImpl(
accountsService = accountsService,
devicesService = devicesService,
@@ -70,5 +72,6 @@ object AuthRepositoryModule {
userLogoutManager = userLogoutManager,
pushManager = pushManager,
policyManager = policyManager,
featureFlagManager = featureFlagManager,
)
}

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

@@ -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,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

@@ -0,0 +1,24 @@
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("/#/", "/")
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

@@ -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

@@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.autofill.fido2.processor.Fido2ProviderProcessorI
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
@@ -35,12 +36,18 @@ object Fido2ProviderModule {
fun provideCredentialProviderProcessor(
@ApplicationContext context: Context,
authRepository: AuthRepository,
vaultRepository: VaultRepository,
fido2CredentialStore: Fido2CredentialStore,
fido2CredentialManager: Fido2CredentialManager,
dispatcherManager: DispatcherManager,
intentManager: IntentManager,
): Fido2ProviderProcessor =
Fido2ProviderProcessorImpl(
context,
authRepository,
vaultRepository,
fido2CredentialStore,
fido2CredentialManager,
intentManager,
dispatcherManager,
)

View File

@@ -1,22 +1,30 @@
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.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 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].
*/
@@ -25,11 +33,18 @@ 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 extract FIDO 2 passkey assertion options from the system [requestJson], or null.
*/
fun getPasskeyAssertionOptionsOrNull(
requestJson: String,
): PasskeyAssertionOptions?
/**
* Register a new FIDO 2 credential to a users vault.
@@ -39,4 +54,18 @@ interface Fido2CredentialManager {
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

@@ -5,11 +5,14 @@ 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.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
@@ -19,8 +22,10 @@ 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
@@ -30,6 +35,7 @@ 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,
@@ -41,24 +47,30 @@ class Fido2CredentialManagerImpl(
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
fido2CredentialRequest
.callingAppInfo
.getAppOrigin(),
)
}
val origin = fido2CredentialRequest.origin
?: fido2CredentialRequest.callingAppInfo.getAppOrigin()
.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(
@@ -93,17 +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
}
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 {
@@ -194,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()
@@ -204,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,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,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")
@@ -32,7 +32,7 @@ data class PublicKeyCredentialCreationOptions(
@SerialName("residentKey")
val residentKeyRequirement: ResidentKeyRequirement? = null,
@SerialName("userVerification")
val userVerification: UserVerificationRequirement? = null,
val userVerification: UserVerificationRequirement = UserVerificationRequirement.PREFERRED,
) {
/**
* Enum class representing the types of attachments associated with selection criteria.
@@ -63,46 +63,8 @@ data class PublicKeyCredentialCreationOptions(
@SerialName("required")
REQUIRED,
}
/**
* 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,
}
}
/**
* 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,35 +10,52 @@ 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.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,
dispatcherManager: DispatcherManager,
) : Fido2ProviderProcessor {
@@ -51,22 +68,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(
@@ -124,10 +142,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,26 +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.
*/
@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
@@ -35,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

@@ -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,
)
/**

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,
@@ -108,6 +120,7 @@ class AutofillParserImpl(
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
}
}
@@ -148,9 +161,29 @@ private fun AssistStructure.traverse(): List<ViewNodeTraversalData> =
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
@@ -164,26 +197,13 @@ private fun ViewNodeTraversalData.updateForMissingUsernameFields(): ViewNodeTrav
return if (passwordPositions.any() &&
this.autofillViews.none { it is AutofillView.Login.Username }
) {
val updatedAutofillViews = autofillViews.mapIndexed { index, autofillView ->
this.copyAndMapAutofillViews { index, autofillView ->
if (autofillView is AutofillView.Unused && passwordPositions.contains(index + 1)) {
AutofillView.Login.Username(data = autofillView.data)
} else {
autofillView
}
}
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
this.copy(
autofillViews = updatedAutofillViews,
ignoreAutofillIds = this.ignoreAutofillIds - unignoredAutofillIds,
)
} else {
// We already have username fields available or there are no password fields, so no need
// to search for them.
@@ -191,6 +211,29 @@ private fun ViewNodeTraversalData.updateForMissingUsernameFields(): ViewNodeTrav
}
}
/**
* 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
* data into [ViewNodeTraversalData].

View File

@@ -13,6 +13,7 @@ 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
@@ -34,6 +35,7 @@ class AutofillProcessorImpl(
private val parser: AutofillParser,
private val saveInfoBuilder: SaveInfoBuilder,
private val settingsRepository: SettingsRepository,
private val crashLogsManager: CrashLogsManager,
) : AutofillProcessor {
/**
@@ -55,11 +57,14 @@ class AutofillProcessorImpl(
// Set the listener so that any long running work is cancelled when it is no longer needed.
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(
@@ -106,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,
@@ -118,27 +123,30 @@ class AutofillProcessorImpl(
)
when (autofillRequest) {
is AutofillRequest.Fillable -> {
job.cancel()
job = 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

@@ -49,4 +49,4 @@ 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().not()
get() = deletedDate == null && !(login?.fido2Credentials.isNullOrEmpty())

View File

@@ -94,6 +94,7 @@ fun AssistStructure.ViewNode.toAutofillView(): AutofillView? =
autofillType = this.autofillType,
isFocused = this.isFocused,
textValue = this.autofillValue?.extractTextValue(),
hasPasswordTerms = this.hasPasswordTerms(),
)
buildAutofillView(
autofillOptions = autofillOptions,
@@ -171,8 +172,6 @@ fun AssistStructure.ViewNode.isPasswordField(
): 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)
@@ -183,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

@@ -9,6 +9,8 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.security.GeneralSecurityException
import java.security.KeyStore
import javax.inject.Singleton
/**
@@ -35,14 +37,57 @@ object PreferenceModule {
fun provideEncryptedSharedPreferences(
application: Application,
): SharedPreferences =
EncryptedSharedPreferences
.create(
application,
"${application.packageName}_encrypted_preferences",
MasterKey.Builder(application)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
@Suppress("TooGenericExceptionCaught")
try {
getEncryptedSharedPreferences(application = application)
} catch (e: GeneralSecurityException) {
// Handle when a bad master key or key-set has been attempted
destroyEncryptedSharedPreferencesAndRebuild(application = application)
} catch (e: RuntimeException) {
// Handle KeystoreExceptions that get wrapped up in a RuntimeException
destroyEncryptedSharedPreferencesAndRebuild(application = application)
}
/**
* Completely destroys the keystore master key and encrypted shared preferences file. This will
* cause all users to be logged out since the access and refresh tokens will be removed.
*
* This is not desirable and should only be called if we have completely failed to access our
* encrypted shared preferences instance.
*/
private fun destroyEncryptedSharedPreferencesAndRebuild(
application: Application,
): SharedPreferences {
// Delete the master key
KeyStore.getInstance(KeyStore.getDefaultType()).run {
load(null)
deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS)
}
// Deletes the encrypted shared preferences file
application.deleteSharedPreferences(application.encryptedSharedPreferencesName)
// Attempts to create the encrypted shared preferences instance
return getEncryptedSharedPreferences(application = application)
}
/**
* Helper method to get the app's encrypted shared preferences instance.
*/
private fun getEncryptedSharedPreferences(
application: Application,
): SharedPreferences =
EncryptedSharedPreferences.create(
application,
application.encryptedSharedPreferencesName,
MasterKey.Builder(application)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
/**
* Helper method to get the app's encrypted shared preferences name.
*/
private val Application.encryptedSharedPreferencesName: String
get() = "${packageName}_encrypted_preferences"
}

View File

@@ -11,18 +11,15 @@ abstract class BaseDiskSource(
private val sharedPreferences: SharedPreferences,
) {
/**
* Gets the [Boolean] for the given [key] from [SharedPreferences], or return the [default]
* value if that key is not present.
* Gets the [Boolean] for the given [key] from [SharedPreferences], or returns `null` if that
* key is not present.
*/
protected fun getBoolean(
key: String,
default: Boolean? = null,
): Boolean? =
protected fun getBoolean(key: String): Boolean? =
if (sharedPreferences.contains(key.withBase())) {
sharedPreferences.getBoolean(key.withBase(), false)
} else {
// Make sure we can return a null value as a default if necessary
default
null
}
/**
@@ -42,18 +39,15 @@ abstract class BaseDiskSource(
}
/**
* Gets the [Int] for the given [key] from [SharedPreferences], or return the [default] value
* if that key is not present.
* Gets the [Int] for the given [key] from [SharedPreferences], or returns `null` if that key
* is not present.
*/
protected fun getInt(
key: String,
default: Int? = null,
): Int? =
protected fun getInt(key: String): Int? =
if (sharedPreferences.contains(key.withBase())) {
sharedPreferences.getInt(key.withBase(), 0)
} else {
// Make sure we can return a null value as a default if necessary
default
null
}
/**
@@ -73,18 +67,15 @@ abstract class BaseDiskSource(
}
/**
* Gets the [Long] for the given [key] from [SharedPreferences], or return the [default] value
* if that key is not present.
* Gets the [Long] for the given [key] from [SharedPreferences], or returns `null` if that key
* is not present.
*/
protected fun getLong(
key: String,
default: Long? = null,
): Long? =
protected fun getLong(key: String): Long? =
if (sharedPreferences.contains(key.withBase())) {
sharedPreferences.getLong(key.withBase(), 0)
} else {
// Make sure we can return a null value as a default if necessary
default
null
}
/**
@@ -105,8 +96,7 @@ abstract class BaseDiskSource(
protected fun getString(
key: String,
default: String? = null,
): String? = sharedPreferences.getString(key.withBase(), default)
): String? = sharedPreferences.getString(key.withBase(), null)
protected fun putString(
key: String,

View File

@@ -0,0 +1,21 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import kotlinx.coroutines.flow.Flow
/**
* Primary access point for server configuration-related disk information.
*/
interface ConfigDiskSource {
/**
* The currently persisted [ServerConfig] (or `null` if not set).
*/
var serverConfig: ServerConfig?
/**
* Emits updates that track [ServerConfig]. This will replay the last known value,
* if any.
*/
val serverConfigFlow: Flow<ServerConfig?>
}

View File

@@ -0,0 +1,37 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import android.content.SharedPreferences
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.util.decodeFromStringOrNull
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onSubscription
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private const val SERVER_CONFIGURATIONS = "serverConfigurations"
/**
* Primary implementation of [ConfigDiskSource].
*/
class ConfigDiskSourceImpl(
sharedPreferences: SharedPreferences,
private val json: Json,
) : BaseDiskSource(sharedPreferences = sharedPreferences),
ConfigDiskSource {
override var serverConfig: ServerConfig?
get() = getString(key = SERVER_CONFIGURATIONS)?.let { json.decodeFromStringOrNull(it) }
set(value) {
putString(
key = SERVER_CONFIGURATIONS,
value = value?.let { json.encodeToString(it) },
)
mutableServerConfigFlow.tryEmit(value)
}
override val serverConfigFlow: Flow<ServerConfig?>
get() = mutableServerConfigFlow.onSubscription { emit(serverConfig) }
private val mutableServerConfigFlow = bufferedMutableSharedFlow<ServerConfig?>(replay = 1)
}

View File

@@ -17,4 +17,14 @@ interface EnvironmentDiskSource {
* if any.
*/
val preAuthEnvironmentUrlDataFlow: Flow<EnvironmentUrlDataJson?>
/**
* Gets the pre authentication urls for the given [userEmail].
*/
fun getPreAuthEnvironmentUrlDataForEmail(userEmail: String): EnvironmentUrlDataJson?
/**
* Stores the [urls] for the given [userEmail].
*/
fun storePreAuthEnvironmentUrlDataForEmail(userEmail: String, urls: EnvironmentUrlDataJson)
}

View File

@@ -10,6 +10,7 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private const val PRE_AUTH_URLS_KEY = "preAuthEnvironmentUrls"
private const val EMAIL_VERIFICATION_URLS = "emailVerificationUrls"
/**
* Primary implementation of [EnvironmentDiskSource].
@@ -24,7 +25,7 @@ class EnvironmentDiskSourceImpl(
set(value) {
putString(
key = PRE_AUTH_URLS_KEY,
value = value?.let { json.encodeToString(value) },
value = value?.let { json.encodeToString(it) },
)
mutableEnvironmentUrlDataFlow.tryEmit(value)
}
@@ -35,4 +36,22 @@ class EnvironmentDiskSourceImpl(
private val mutableEnvironmentUrlDataFlow =
bufferedMutableSharedFlow<EnvironmentUrlDataJson?>(replay = 1)
override fun getPreAuthEnvironmentUrlDataForEmail(
userEmail: String,
): EnvironmentUrlDataJson? =
getString(key = EMAIL_VERIFICATION_URLS.appendIdentifier(userEmail))
?.let {
json.decodeFromStringOrNull(it)
}
override fun storePreAuthEnvironmentUrlDataForEmail(
userEmail: String,
urls: EnvironmentUrlDataJson,
) {
putString(
key = EMAIL_VERIFICATION_URLS.appendIdentifier(userEmail),
value = json.encodeToString(urls),
)
}
}

View File

@@ -35,7 +35,7 @@ class PushDiskSourceImpl(
}
override fun getLastPushTokenRegistrationDate(userId: String): ZonedDateTime? {
return getLong(LAST_REGISTRATION_DATE_KEY.appendIdentifier(userId), null)
return getLong(LAST_REGISTRATION_DATE_KEY.appendIdentifier(userId))
?.let { getZoneDateTimeFromBinaryLong(it) }
}

View File

@@ -6,6 +6,8 @@ import android.content.SharedPreferences
import androidx.room.Room
import com.x8bit.bitwarden.data.platform.datasource.di.EncryptedPreferences
import com.x8bit.bitwarden.data.platform.datasource.di.UnencryptedPreferences
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSource
@@ -51,6 +53,17 @@ object PlatformDiskModule {
json = json,
)
@Provides
@Singleton
fun provideConfigDiskSource(
@UnencryptedPreferences sharedPreferences: SharedPreferences,
json: Json,
): ConfigDiskSource =
ConfigDiskSourceImpl(
sharedPreferences = sharedPreferences,
json = json,
)
@Provides
@Singleton
fun provideEventDatabase(app: Application): PlatformDatabase =

View File

@@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.platform.datasource.disk.model
import com.x8bit.bitwarden.data.platform.datasource.network.model.ConfigResponseJson
import kotlinx.serialization.Serializable
/**
* A higher-level wrapper around [ConfigResponseJson] that provides a timestamp
* to check if a sync is necessary
*
* @property lastSync The [Long] of the last sync.
* @property serverData The raw [ConfigResponseJson] that contains specific data of the
* server configuration
*/
@Serializable
data class ServerConfig(
val lastSync: Long,
val serverData: ConfigResponseJson,
)

View File

@@ -9,9 +9,13 @@ import retrofit2.Call
import retrofit2.Callback
import retrofit2.HttpException
import retrofit2.Response
import retrofit2.Response.success
import java.lang.reflect.Type
/**
* The integer code value for a "No Content" response.
*/
private const val NO_CONTENT_RESPONSE_CODE: Int = 204
/**
* A [Call] for wrapping a network request into a [Result].
*/
@@ -23,58 +27,28 @@ class ResultCall<T>(
override fun clone(): Call<Result<T>> = ResultCall(backingCall, successType)
@Suppress("UNCHECKED_CAST")
private fun createResult(body: T?): Result<T> {
return when {
body != null -> body.asSuccess()
successType == Unit::class.java -> (Unit as T).asSuccess()
else -> IllegalStateException("Unexpected null body!").asFailure()
}
}
override fun enqueue(callback: Callback<Result<T>>): Unit = backingCall.enqueue(
object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
val body = response.body()
val result: Result<T> = if (!response.isSuccessful) {
HttpException(response).asFailure()
} else {
createResult(body)
}
callback.onResponse(this@ResultCall, success(result))
callback.onResponse(this@ResultCall, Response.success(response.toResult()))
}
override fun onFailure(call: Call<T>, t: Throwable) {
val result: Result<T> = t.asFailure()
callback.onResponse(this@ResultCall, success(result))
callback.onResponse(this@ResultCall, Response.success(t.asFailure()))
}
},
)
/**
* Synchronously send the request and return its response as a [Result].
*/
fun executeForResult(): Result<T> = requireNotNull(execute().body())
@Suppress("TooGenericExceptionCaught")
override fun execute(): Response<Result<T>> {
val response = try {
backingCall.execute()
override fun execute(): Response<Result<T>> =
try {
Response.success(backingCall.execute().toResult())
} catch (ioException: IOException) {
return success(ioException.asFailure())
Response.success(ioException.asFailure())
} catch (runtimeException: RuntimeException) {
return success(runtimeException.asFailure())
Response.success(runtimeException.asFailure())
}
return success(
if (!response.isSuccessful) {
HttpException(response).asFailure()
} else {
createResult(response.body())
},
)
}
override fun isCanceled(): Boolean = backingCall.isCanceled
override fun isExecuted(): Boolean = backingCall.isExecuted
@@ -82,4 +56,27 @@ class ResultCall<T>(
override fun request(): Request = backingCall.request()
override fun timeout(): Timeout = backingCall.timeout()
/**
* Synchronously send the request and return its response as a [Result].
*/
fun executeForResult(): Result<T> = requireNotNull(execute().body())
private fun Response<T>.toResult(): Result<T> =
if (!this.isSuccessful) {
HttpException(this).asFailure()
} else {
val body = this.body()
@Suppress("UNCHECKED_CAST")
when {
// We got a nonnull T as the body, just return it.
body != null -> body.asSuccess()
// We expected the body to be null since the successType is Unit, just return Unit.
successType == Unit::class.java -> (Unit as T).asSuccess()
// We allow null for 204's, just return null.
this.code() == NO_CONTENT_RESPONSE_CODE -> (null as T).asSuccess()
// All other null bodies result in an error.
else -> IllegalStateException("Unexpected null body!").asFailure()
}
}
}

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.datasource.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonPrimitive
/**
* Represents the response model for configuration data fetched from the server.
@@ -31,7 +32,7 @@ data class ConfigResponseJson(
val environment: EnvironmentJson?,
@SerialName("featureStates")
val featureStates: Map<String, Boolean>?,
val featureStates: Map<String, JsonPrimitive>?,
) {
/**
* Represents a server in the configuration response.

View File

@@ -2,6 +2,7 @@ package com.x8bit.bitwarden.data.platform.datasource.network.retrofit
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
import retrofit2.Retrofit
import retrofit2.http.Url
/**
* A collection of various [Retrofit] instances that serve different purposes.
@@ -36,11 +37,14 @@ interface Retrofits {
val unauthenticatedIdentityRetrofit: Retrofit
/**
* Allows access to static API calls (ex: external APIs) that do not therefore require
* authentication with Bitwarden's servers.
* Allows access to static API calls (ex: external APIs).
*
* No base URL is supplied as part of the builder and no longer is added to make this URL
* dynamically updatable.
* @param isAuthenticated Indicates if the [Retrofit] instance should use authentication.
* @param baseUrl The static base url associated with this retrofit instance. This can be
* overridden with the [Url] annotation.
*/
val staticRetrofitBuilder: Retrofit.Builder
fun createStaticRetrofit(
isAuthenticated: Boolean = false,
baseUrl: String = "https://api.bitwarden.com",
): Retrofit
}

View File

@@ -60,19 +60,22 @@ class RetrofitsImpl(
//endregion Unauthenticated Retrofits
//region Other Retrofits
//region Static Retrofit
override val staticRetrofitBuilder: Retrofit.Builder
get() =
baseRetrofitBuilder
.client(
baseOkHttpClient
.newBuilder()
.addInterceptor(loggingInterceptor)
.build(),
)
override fun createStaticRetrofit(isAuthenticated: Boolean, baseUrl: String): Retrofit {
val baseClient = if (isAuthenticated) authenticatedOkHttpClient else baseOkHttpClient
return baseRetrofitBuilder
.baseUrl(baseUrl)
.client(
baseClient
.newBuilder()
.addInterceptor(loggingInterceptor)
.build(),
)
.build()
}
//endregion Other Retrofits
//endregion Static Retrofit
//region Helper properties and functions
private val loggingInterceptor: HttpLoggingInterceptor by lazy {

View File

@@ -0,0 +1,35 @@
package com.x8bit.bitwarden.data.platform.datasource.sdk
import android.util.Log
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
/**
* Base class for simplifying sdk interactions.
*/
@Suppress("UnnecessaryAbstractClass")
abstract class BaseSdkSource(
protected val sdkClientManager: SdkClientManager,
) {
/**
* Helper function to retrieve the [Client] associated with the given [userId].
*/
protected suspend fun getClient(
userId: String? = null,
): Client = sdkClientManager.getOrCreateClient(userId = userId)
/**
* Invokes the [block] with `this` value as its receiver and returns its result if it was
* successful and catches any exception that was thrown from the `block` and wrapping it as a
* failure.
*/
protected inline fun <T, R> T.runCatchingWithLogs(
block: T.() -> R,
): Result<R> = runCatching(block = block)
.onFailure {
if (BuildConfig.DEBUG) {
Log.w(this@BaseSdkSource::class.java.simpleName, it)
}
}
}

View File

@@ -0,0 +1,38 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import kotlinx.coroutines.flow.Flow
/**
* Manages the available feature flags for the Bitwarden application.
*/
interface FeatureFlagManager {
/**
* Returns a map of constant feature flags that are only used locally.
*/
val sdkFeatureFlags: Map<String, Boolean>
/**
* Returns a flow emitting the value of flag [key] which is of generic type [T].
* If the value of the flag cannot be retrieved, the default value of [key] will be returned
*/
fun <T : Any> getFeatureFlagFlow(key: FlagKey<T>): Flow<T>
/**
* Get value for feature flag with [key] and returns it as generic type [T].
* If no value is found the given [key] its default value will be returned.
* Cached flags can be invalidated with [forceRefresh]
*/
suspend fun <T : Any> getFeatureFlag(
key: FlagKey<T>,
forceRefresh: Boolean,
): T
/**
* Gets the value for feature flag with [key] and returns it as generic type [T].
* If no value is found the given [key] its [FlagKey.defaultValue] will be returned.
*/
fun <T : Any> getFeatureFlag(
key: FlagKey<T>,
): T
}

View File

@@ -0,0 +1,67 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private const val CIPHER_KEY_ENCRYPTION_KEY = "enableCipherKeyEncryption"
/**
* Primary implementation of [FeatureFlagManager].
*/
class FeatureFlagManagerImpl(
private val serverConfigRepository: ServerConfigRepository,
) : FeatureFlagManager {
override val sdkFeatureFlags: Map<String, Boolean>
get() = mapOf(CIPHER_KEY_ENCRYPTION_KEY to true)
override fun <T : Any> getFeatureFlagFlow(key: FlagKey<T>): Flow<T> =
serverConfigRepository
.serverConfigStateFlow
.map { serverConfig ->
serverConfig.getFlagValueOrDefault(key = key)
}
override suspend fun <T : Any> getFeatureFlag(
key: FlagKey<T>,
forceRefresh: Boolean,
): T =
serverConfigRepository
.getServerConfig(forceRefresh = forceRefresh)
.getFlagValueOrDefault(key = key)
override fun <T : Any> getFeatureFlag(key: FlagKey<T>): T =
serverConfigRepository
.serverConfigStateFlow
.value
.getFlagValueOrDefault(key = key)
}
private fun <T : Any> ServerConfig?.getFlagValueOrDefault(key: FlagKey<T>): T {
val defaultValue = key.defaultValue
if (!key.isRemotelyConfigured) return key.defaultValue
return this
?.serverData
?.featureStates
?.get(key.keyName)
?.let {
try {
// Suppressed since we are checking the type before doing the cast
@Suppress("UNCHECKED_CAST")
when (defaultValue::class) {
Boolean::class -> it.content.toBoolean() as T
String::class -> it.content as T
Int::class -> it.content.toInt() as T
else -> defaultValue
}
} catch (ex: ClassCastException) {
defaultValue
} catch (ex: NumberFormatException) {
defaultValue
}
}
?: defaultValue
}

View File

@@ -7,17 +7,23 @@ import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthToke
import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
private const val ENVIRONMENT_DEBOUNCE_TIMEOUT_MS: Long = 500L
/**
* Primary implementation of [NetworkConfigManager].
*/
@Suppress("LongParameterList")
class NetworkConfigManagerImpl(
authRepository: AuthRepository,
private val authTokenInterceptor: AuthTokenInterceptor,
environmentRepository: EnvironmentRepository,
serverConfigRepository: ServerConfigRepository,
private val baseUrlInterceptors: BaseUrlInterceptors,
refreshAuthenticator: RefreshAuthenticator,
dispatcherManager: DispatcherManager,
@@ -37,11 +43,18 @@ class NetworkConfigManagerImpl(
}
.launchIn(collectionScope)
@Suppress("OPT_IN_USAGE")
environmentRepository
.environmentStateFlow
.onEach { environment ->
baseUrlInterceptors.environment = environment
}
.debounce(timeoutMillis = ENVIRONMENT_DEBOUNCE_TIMEOUT_MS)
.onEach { _ ->
// This updates the stored service configuration by performing a network request.
// We debounce it to avoid rapid repeated requests.
serverConfigRepository.getServerConfig(forceRefresh = true)
}
.launchIn(collectionScope)
refreshAuthenticator.authenticatorProvider = authRepository

View File

@@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.platform.manager
/**
* A manager for caching resources that are large and could be performance impacting to load
* multiple times.
*/
interface ResourceCacheManager {
/**
* Retrieves the exception suffix list used for matching a cipher against a domain.
*/
val domainExceptionSuffixes: List<String>
/**
* Retrieves the normal suffix list used for matching a cipher against a domain.
*/
val domainNormalSuffixes: List<String>
/**
* Retrieves the wild card suffix list used for matching a cipher against a domain.
*/
val domainWildCardSuffixes: List<String>
}

View File

@@ -0,0 +1,32 @@
package com.x8bit.bitwarden.data.platform.manager
import android.content.Context
import com.x8bit.bitwarden.R
/**
* Primary implementation of [ResourceCacheManager].
*/
class ResourceCacheManagerImpl(
private val context: Context,
) : ResourceCacheManager {
override val domainExceptionSuffixes: List<String> by lazy {
context
.resources
.getStringArray(R.array.exception_suffixes)
.toList()
}
override val domainNormalSuffixes: List<String> by lazy {
context
.resources
.getStringArray(R.array.normal_suffixes)
.toList()
}
override val domainWildCardSuffixes: List<String> by lazy {
context
.resources
.getStringArray(R.array.wild_card_suffixes)
.toList()
}
}

View File

@@ -1,16 +1,15 @@
package com.x8bit.bitwarden.data.platform.manager
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.vault.datasource.sdk.BitwardenFeatureFlagManager
/**
* Primary implementation of [SdkClientManager].
*/
class SdkClientManagerImpl(
private val featureFlagManager: BitwardenFeatureFlagManager,
private val featureFlagManager: FeatureFlagManager,
private val clientProvider: suspend () -> Client = {
Client(settings = null).apply {
platform().loadFlags(featureFlagManager.featureFlags)
platform().loadFlags(featureFlagManager.sdkFeatureFlags)
}
},
) : SdkClientManager {

View File

@@ -1,10 +1,11 @@
package com.x8bit.bitwarden.data.platform.manager.ciphermatching
import android.content.Context
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.LoginUriView
import com.bitwarden.vault.UriMatchType
import com.x8bit.bitwarden.data.platform.manager.ResourceCacheManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.firstWithTimeoutOrNull
import com.x8bit.bitwarden.data.platform.util.getDomainOrNull
import com.x8bit.bitwarden.data.platform.util.getHostWithPortOrNull
import com.x8bit.bitwarden.data.platform.util.getWebHostFromAndroidUriOrNull
@@ -13,7 +14,6 @@ import com.x8bit.bitwarden.data.platform.util.regexOrNull
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DomainsData
import com.x8bit.bitwarden.ui.platform.feature.settings.autofill.util.toSdkUriMatchType
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.mapNotNull
import kotlin.text.RegexOption
import kotlin.text.isNullOrBlank
@@ -21,12 +21,17 @@ import kotlin.text.lowercase
import kotlin.text.matches
import kotlin.text.startsWith
/**
* The duration, in milliseconds, we should wait while retrieving domain data before we proceed.
*/
private const val GET_DOMAINS_TIMEOUT_MS: Long = 1_000L
/**
* The default [CipherMatchingManager] implementation. This class is responsible for matching
* ciphers based on special criteria.
*/
class CipherMatchingManagerImpl(
private val context: Context,
private val resourceCacheManager: ResourceCacheManager,
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
) : CipherMatchingManager {
@@ -37,12 +42,13 @@ class CipherMatchingManagerImpl(
val equivalentDomainsData = vaultRepository
.domainsStateFlow
.mapNotNull { it.data }
.first()
.firstWithTimeoutOrNull(timeMillis = GET_DOMAINS_TIMEOUT_MS)
?: return emptyList()
val isAndroidApp = matchUri.isAndroidApp()
val defaultUriMatchType = settingsRepository.defaultUriMatchType.toSdkUriMatchType()
val domain = matchUri
.getDomainOrNull(context = context)
.getDomainOrNull(resourceCacheManager = resourceCacheManager)
?.lowercase()
// Retrieve domains that are considered equivalent to the specified matchUri for cipher
@@ -61,8 +67,8 @@ class CipherMatchingManagerImpl(
ciphers
.forEach { cipherView ->
val matchResult = checkForCipherMatch(
resourceCacheManager = resourceCacheManager,
cipherView = cipherView,
context = context,
defaultUriMatchType = defaultUriMatchType,
isAndroidApp = isAndroidApp,
matchUri = matchUri,
@@ -136,7 +142,7 @@ private fun getMatchingDomains(
* provide details on the match quality.
*
* @param cipherView The cipher to be judged for a match.
* @param context A context for getting string resources.
* @param resourceCacheManager The [ResourceCacheManager] for fetching cached resources.
* @param defaultUriMatchType The global default [UriMatchType].
* @param isAndroidApp Whether or not the [matchUri] belongs to an Android app.
* @param matchingDomains The set of domains that match the domain of [matchUri].
@@ -144,8 +150,8 @@ private fun getMatchingDomains(
*/
@Suppress("LongParameterList")
private fun checkForCipherMatch(
resourceCacheManager: ResourceCacheManager,
cipherView: CipherView,
context: Context,
defaultUriMatchType: UriMatchType,
isAndroidApp: Boolean,
matchingDomains: MatchingDomains,
@@ -156,7 +162,7 @@ private fun checkForCipherMatch(
?.uris
?.map { loginUriView ->
loginUriView.checkForMatch(
context = context,
resourceCacheManager = resourceCacheManager,
defaultUriMatchType = defaultUriMatchType,
isAndroidApp = isAndroidApp,
matchingDomains = matchingDomains,
@@ -174,14 +180,14 @@ private fun checkForCipherMatch(
/**
* Check to see how well this [LoginUriView] matches [matchUri].
*
* @param context A context for getting app information.
* @param resourceCacheManager The [ResourceCacheManager] for fetching cached resources.
* @param defaultUriMatchType The global default [UriMatchType].
* @param isAndroidApp Whether or not the [matchUri] belongs to an Android app.
* @param matchingDomains The set of domains that match the domain of [matchUri].
* @param matchUri The uri that this [LoginUriView] is being matched to.
*/
private fun LoginUriView.checkForMatch(
context: Context,
resourceCacheManager: ResourceCacheManager,
defaultUriMatchType: UriMatchType,
isAndroidApp: Boolean,
matchingDomains: MatchingDomains,
@@ -194,7 +200,7 @@ private fun LoginUriView.checkForMatch(
when (matchType) {
UriMatchType.DOMAIN -> {
checkUriForDomainMatch(
context = context,
resourceCacheManager = resourceCacheManager,
isAndroidApp = isAndroidApp,
matchingDomains = matchingDomains,
uri = loginViewUri,
@@ -228,8 +234,8 @@ private fun LoginUriView.checkForMatch(
* Check to see if [uri] matches [matchingDomains] in some way.
*/
private fun checkUriForDomainMatch(
resourceCacheManager: ResourceCacheManager,
isAndroidApp: Boolean,
context: Context,
matchingDomains: MatchingDomains,
uri: String,
): MatchResult = when {
@@ -237,7 +243,7 @@ private fun checkUriForDomainMatch(
isAndroidApp && matchingDomains.fuzzyMatches.contains(uri) -> MatchResult.FUZZY
else -> {
val domain = uri
.getDomainOrNull(context = context)
.getDomainOrNull(resourceCacheManager = resourceCacheManager)
?.lowercase()
// We only care about fuzzy matches if we are isAndroidApp is true because the fuzzu

View File

@@ -22,6 +22,8 @@ import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManagerImpl
import com.x8bit.bitwarden.data.platform.manager.CrashLogsManager
import com.x8bit.bitwarden.data.platform.manager.CrashLogsManagerImpl
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManagerImpl
import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManager
import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManagerImpl
import com.x8bit.bitwarden.data.platform.manager.NetworkConnectionManager
@@ -30,6 +32,8 @@ import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManagerImpl
import com.x8bit.bitwarden.data.platform.manager.PushManager
import com.x8bit.bitwarden.data.platform.manager.PushManagerImpl
import com.x8bit.bitwarden.data.platform.manager.ResourceCacheManager
import com.x8bit.bitwarden.data.platform.manager.ResourceCacheManagerImpl
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.platform.manager.SdkClientManagerImpl
import com.x8bit.bitwarden.data.platform.manager.ciphermatching.CipherMatchingManager
@@ -45,8 +49,8 @@ import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManage
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManagerImpl
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.vault.datasource.sdk.BitwardenFeatureFlagManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import dagger.Module
import dagger.Provides
@@ -90,12 +94,12 @@ object PlatformManagerModule {
@Provides
@Singleton
fun providesCipherMatchingManager(
@ApplicationContext context: Context,
resourceCacheManager: ResourceCacheManager,
settingsRepository: SettingsRepository,
vaultRepository: VaultRepository,
): CipherMatchingManager =
CipherMatchingManagerImpl(
context = context,
resourceCacheManager = resourceCacheManager,
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
)
@@ -134,10 +138,19 @@ object PlatformManagerModule {
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun providesFeatureFlagManager(
serverConfigRepository: ServerConfigRepository,
): FeatureFlagManager =
FeatureFlagManagerImpl(
serverConfigRepository = serverConfigRepository,
)
@Provides
@Singleton
fun provideSdkClientManager(
featureFlagManager: BitwardenFeatureFlagManager,
featureFlagManager: FeatureFlagManager,
): SdkClientManager = SdkClientManagerImpl(
featureFlagManager = featureFlagManager,
)
@@ -148,6 +161,7 @@ object PlatformManagerModule {
authRepository: AuthRepository,
authTokenInterceptor: AuthTokenInterceptor,
environmentRepository: EnvironmentRepository,
serverConfigRepository: ServerConfigRepository,
baseUrlInterceptors: BaseUrlInterceptors,
refreshAuthenticator: RefreshAuthenticator,
dispatcherManager: DispatcherManager,
@@ -156,6 +170,7 @@ object PlatformManagerModule {
authRepository = authRepository,
authTokenInterceptor = authTokenInterceptor,
environmentRepository = environmentRepository,
serverConfigRepository = serverConfigRepository,
baseUrlInterceptors = baseUrlInterceptors,
refreshAuthenticator = refreshAuthenticator,
dispatcherManager = dispatcherManager,
@@ -229,4 +244,10 @@ object PlatformManagerModule {
environmentRepository = environmentRepository,
restrictionsManager = requireNotNull(context.getSystemService()),
)
@Provides
@Singleton
fun provideResourceCacheManager(
@ApplicationContext context: Context,
): ResourceCacheManager = ResourceCacheManagerImpl(context = context)
}

View File

@@ -0,0 +1,18 @@
package com.x8bit.bitwarden.data.platform.manager.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Required data to complete ongoing registration process.
*
* @property email The email of the user creating the account.
* @property verificationToken The token required to finish the registration process.
* @property fromEmail indicates that this information came from an email AppLink.
*/
@Parcelize
data class CompleteRegistrationData(
val email: String,
val verificationToken: String,
val fromEmail: Boolean,
) : Parcelable

View File

@@ -0,0 +1,76 @@
package com.x8bit.bitwarden.data.platform.manager.model
/**
* Class to hold feature flag keys.
*/
sealed class FlagKey<out T : Any> {
/**
* The string value of the given key. This must match the network value.
*/
abstract val keyName: String
/**
* The value to be used if the flags value cannot be determined or is not remotely configured.
*/
abstract val defaultValue: T
/**
* Indicates if the flag should respect the network value or not.
*/
abstract val isRemotelyConfigured: Boolean
/**
* Data object holding the key for Email Verification feature.
*/
data object EmailVerification : FlagKey<Boolean>() {
override val keyName: String = "email-verification"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = false
}
/**
* Data object holding the feature flag key for the Onboarding Carousel feature.
*/
data object OnboardingCarousel : FlagKey<Boolean>() {
override val keyName: String = "native-carousel-flow"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = false
}
/**
* Data object holding the feature flag key for the new onboarding feature.
*/
data object OnboardingFlow : FlagKey<Boolean>() {
override val keyName: String = "native-create-account-flow"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = false
}
/**
* Data object holding the key for a [Boolean] flag to be used in tests.
*/
data object DummyBoolean : FlagKey<Boolean>() {
override val keyName: String = "dummy-boolean"
override val defaultValue: Boolean = false
override val isRemotelyConfigured: Boolean = true
}
/**
* Data object holding the key for an [Int] flag to be used in tests.
*/
data class DummyInt(
override val isRemotelyConfigured: Boolean = true,
) : FlagKey<Int>() {
override val keyName: String = "dummy-int"
override val defaultValue: Int = Int.MIN_VALUE
}
/**
* Data object holding the key for a [String] flag to be used in tests.
*/
data object DummyString : FlagKey<String>() {
override val keyName: String = "dummy-string"
override val defaultValue: String = "defaultValue"
override val isRemotelyConfigured: Boolean = true
}
}

View File

@@ -1,7 +1,9 @@
package com.x8bit.bitwarden.data.platform.manager.model
import android.os.Parcelable
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
@@ -48,6 +50,15 @@ sealed class SpecialCircumstance : Parcelable {
val shouldFinishWhenComplete: Boolean,
) : SpecialCircumstance()
/**
* The app was launched via AppLink in order to allow the user complete an ongoing registration.
*/
@Parcelize
data class CompleteRegistration(
val completeRegistrationData: CompleteRegistrationData,
val timestamp: Long,
) : SpecialCircumstance()
/**
* The app was launched via the credential manager framework in order to allow the user to
* manually save a passkey to their vault.
@@ -57,6 +68,24 @@ sealed class SpecialCircumstance : Parcelable {
val fido2CredentialRequest: Fido2CredentialRequest,
) : SpecialCircumstance()
/**
* The app was launched via the credential manager framework in order to authenticate a FIDO 2
* credential saved to the user's vault.
*/
@Parcelize
data class Fido2Assertion(
val fido2AssertionRequest: Fido2CredentialAssertionRequest,
) : SpecialCircumstance()
/**
* The app was launched via the credential manager framework request to retrieve passkeys
* associated with the requesting entity.
*/
@Parcelize
data class Fido2GetCredentials(
val fido2GetCredentialsRequest: Fido2GetCredentialsRequest,
) : SpecialCircumstance()
/**
* The app was launched via deeplink to the generator.
*/

View File

@@ -1,6 +1,8 @@
package com.x8bit.bitwarden.data.platform.manager.util
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialAssertionRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2CredentialRequest
import com.x8bit.bitwarden.data.autofill.fido2.model.Fido2GetCredentialsRequest
import com.x8bit.bitwarden.data.autofill.model.AutofillSaveItem
import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
@@ -17,6 +19,9 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? =
SpecialCircumstance.GeneratorShortcut -> null
SpecialCircumstance.VaultShortcut -> null
is SpecialCircumstance.Fido2Save -> null
is SpecialCircumstance.CompleteRegistration -> null
is SpecialCircumstance.Fido2Assertion -> null
is SpecialCircumstance.Fido2GetCredentials -> null
}
/**
@@ -31,6 +36,9 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData?
SpecialCircumstance.GeneratorShortcut -> null
SpecialCircumstance.VaultShortcut -> null
is SpecialCircumstance.Fido2Save -> null
is SpecialCircumstance.CompleteRegistration -> null
is SpecialCircumstance.Fido2Assertion -> null
is SpecialCircumstance.Fido2GetCredentials -> null
}
/**
@@ -41,3 +49,21 @@ fun SpecialCircumstance.toFido2RequestOrNull(): Fido2CredentialRequest? =
is SpecialCircumstance.Fido2Save -> this.fido2CredentialRequest
else -> null
}
/**
* Returns [Fido2CredentialAssertionRequest] when contained in the given [SpecialCircumstance].
*/
fun SpecialCircumstance.toFido2AssertionRequestOrNull(): Fido2CredentialAssertionRequest? =
when (this) {
is SpecialCircumstance.Fido2Assertion -> this.fido2AssertionRequest
else -> null
}
/**
* Returns [Fido2CredentialAssertionRequest] when contained in the given [SpecialCircumstance].
*/
fun SpecialCircumstance.toFido2GetCredentialsRequestOrNull(): Fido2GetCredentialsRequest? =
when (this) {
is SpecialCircumstance.Fido2GetCredentials -> this.fido2GetCredentialsRequest
else -> null
}

View File

@@ -16,4 +16,15 @@ interface EnvironmentRepository {
* Emits updates that track [environment].
*/
val environmentStateFlow: StateFlow<Environment>
/**
* Stores the current environment for the given [userEmail].
*/
fun saveCurrentEnvironmentForEmail(userEmail: String)
/**
* Loads the environment for the given [userEmail].
* returns boolean indicates if the load was successful
*/
fun loadEnvironmentForEmail(userEmail: String): Boolean
}

View File

@@ -4,6 +4,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrls
import com.x8bit.bitwarden.data.platform.repository.util.toEnvironmentUrlsOrDefault
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
@@ -55,4 +56,19 @@ class EnvironmentRepositoryImpl(
}
.launchIn(scope)
}
override fun loadEnvironmentForEmail(userEmail: String): Boolean {
val urls = environmentDiskSource
.getPreAuthEnvironmentUrlDataForEmail(userEmail)
?: return false
environment = urls.toEnvironmentUrls()
return true
}
override fun saveCurrentEnvironmentForEmail(userEmail: String) =
environmentDiskSource
.storePreAuthEnvironmentUrlDataForEmail(
userEmail = userEmail,
urls = environment.environmentUrlData,
)
}

View File

@@ -0,0 +1,21 @@
package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import kotlinx.coroutines.flow.StateFlow
/**
* Provides an API for observing the server config state.
*/
interface ServerConfigRepository {
/**
* Emits updates that track [ServerConfig].
*/
val serverConfigStateFlow: StateFlow<ServerConfig?>
/**
* Gets the state [ServerConfig]. If needed or forced by [forceRefresh],
* updates the values using server side data.
*/
suspend fun getServerConfig(forceRefresh: Boolean): ServerConfig?
}

View File

@@ -0,0 +1,65 @@
package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.model.ServerConfig
import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigService
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import java.time.Clock
import java.time.Instant
/**
* Primary implementation of [ServerConfigRepositoryImpl].
*/
class ServerConfigRepositoryImpl(
private val configDiskSource: ConfigDiskSource,
private val configService: ConfigService,
private val clock: Clock,
dispatcherManager: DispatcherManager,
) : ServerConfigRepository {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
override val serverConfigStateFlow: StateFlow<ServerConfig?>
get() = configDiskSource
.serverConfigFlow
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = configDiskSource.serverConfig,
)
override suspend fun getServerConfig(forceRefresh: Boolean): ServerConfig? {
val localConfig = configDiskSource.serverConfig
val needsRefresh = localConfig == null ||
Instant
.ofEpochMilli(localConfig.lastSync)
.isAfter(
clock.instant().plusSeconds(MINIMUM_CONFIG_SYNC_INTERVAL_SEC),
)
if (needsRefresh || forceRefresh) {
configService
.getConfig()
.onSuccess { configResponse ->
val serverConfig = ServerConfig(
lastSync = clock.instant().toEpochMilli(),
serverData = configResponse,
)
configDiskSource.serverConfig = serverConfig
return serverConfig
}
}
// If we are unable to retrieve a configuration from the server,
// fall back to the local configuration.
return localConfig
}
companion object {
private const val MINIMUM_CONFIG_SYNC_INTERVAL_SEC: Long = 60 * 60
}
}

View File

@@ -3,13 +3,17 @@ package com.x8bit.bitwarden.data.platform.repository.di
import android.view.autofill.AutofillManager
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.autofill.manager.AutofillEnabledManager
import com.x8bit.bitwarden.data.platform.datasource.disk.ConfigDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigService
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepositoryImpl
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepositoryImpl
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepositoryImpl
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@@ -17,6 +21,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import javax.inject.Singleton
/**
@@ -26,6 +31,21 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object PlatformRepositoryModule {
@Provides
@Singleton
fun provideServerConfigRepository(
configDiskSource: ConfigDiskSource,
configService: ConfigService,
clock: Clock,
dispatcherManager: DispatcherManager,
): ServerConfigRepository =
ServerConfigRepositoryImpl(
configDiskSource = configDiskSource,
configService = configService,
clock = clock,
dispatcherManager = dispatcherManager,
)
@Provides
@Singleton
fun provideEnvironmentRepository(

View File

@@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.platform.util
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
/**
* Returns the first element emitted by the [Flow] or `null` if the operation exceeds the given
* timeout of [timeMillis].
*/
suspend fun <T> Flow<T>.firstWithTimeoutOrNull(
timeMillis: Long,
): T? = withTimeoutOrNull(timeMillis = timeMillis) { first() }
/**
* Returns the first element emitted by the [Flow] matching the given [predicate] or `null` if the
* operation exceeds the given timeout of [timeMillis].
*/
suspend fun <T> Flow<T>.firstWithTimeoutOrNull(
timeMillis: Long,
predicate: suspend (T) -> Boolean,
): T? = withTimeoutOrNull(timeMillis = timeMillis) { first(predicate) }

View File

@@ -3,37 +3,24 @@
package com.x8bit.bitwarden.data.platform.util
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import androidx.core.content.IntentCompat
import androidx.core.os.BundleCompat
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
/**
* A means of retrieving a [Parcelable] from an [Intent] using the given [name] in a manner that
* is safe across SDK versions.
*/
inline fun <reified T> Intent.getSafeParcelableExtra(name: String): T? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelableExtra(
name,
T::class.java,
)
} else {
@Suppress("DEPRECATION")
getParcelableExtra(name)
}
inline fun <reified T> Intent.getSafeParcelableExtra(
name: String,
): T? = IntentCompat.getParcelableExtra(this, name, T::class.java)
/**
* A means of retrieving a [Parcelable] from a [Bundle] using the given [name] in a manner that
* is safe across SDK versions.
*/
inline fun <reified T> Bundle.getSafeParcelableExtra(name: String): T? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
getParcelable(
name,
T::class.java,
)
} else {
@Suppress("DEPRECATION")
getParcelable(name)
}
inline fun <reified T> Bundle.getSafeParcelableExtra(
name: String,
): T? = BundleCompat.getParcelable(this, name, T::class.java)

View File

@@ -0,0 +1,67 @@
package com.x8bit.bitwarden.data.platform.util
import java.util.Locale
/**
* String [Comparator] where the characters are compared giving precedence to
* special characters.
*/
object SpecialCharWithPrecedenceComparator : Comparator<String> {
override fun compare(str1: String, str2: String): Int {
val minLength = minOf(str1.length, str2.length)
for (i in 0 until minLength) {
val char1 = str1[i]
val char2 = str2[i]
val compareResult = compareCharsSpecialCharsWithPrecedence(char1, char2)
if (compareResult != 0) {
return compareResult
}
}
// If all compared chars are the same give precedence to the shorter String.
return str1.length - str2.length
}
}
/**
* Compare two characters, where a special character is considered with higher precedence over
* letters and numbers. If both characters are a letter and they are equal ignoring the case,
* give priority to the lowercase instance. If they are both a digit or a non-equal letter
* use the default [String.compareTo] converting the chars to the [Locale] specific uppercase
* String.
*/
private fun compareCharsSpecialCharsWithPrecedence(c1: Char, c2: Char): Int {
return when {
c1.isLetterOrDigit() && !c2.isLetterOrDigit() -> 1
!c1.isLetterOrDigit() && c2.isLetterOrDigit() -> -1
c1.isLetter() && c2.isLetter() && c1.equals(other = c2, ignoreCase = true) -> {
compareLettersLowerCaseFirst(c1 = c1, c2 = c2)
}
else -> {
val upperCaseStr1 = c1.toString().uppercase(Locale.getDefault())
val upperCaseStr2 = c2.toString().uppercase(Locale.getDefault())
upperCaseStr1.compareTo(upperCaseStr2)
}
}
}
/**
* Compare two equal letters ignoring case (i.e. 'A' == 'a'), give precedence to the
* the character which is lowercase. If both [c1] and [c2] are equal and the
* same case return 0 to indicate they are the same.
*/
private fun compareLettersLowerCaseFirst(c1: Char, c2: Char): Int {
require(
value = c1.isLetter() &&
c2.isLetter() &&
c1.equals(other = c2, ignoreCase = true),
) {
"Both character must be the same letter, case does not matter."
}
return when {
!c1.isLowerCase() && c2.isLowerCase() -> 1
c1.isLowerCase() && !c2.isLowerCase() -> -1
else -> 0
}
}

View File

@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.platform.util
import android.content.Context
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.manager.ResourceCacheManager
import java.net.URI
import java.net.URISyntaxException
@@ -47,28 +47,26 @@ fun String.getWebHostFromAndroidUriOrNull(): String? =
/**
* Extract the domain name from this [String] if possible, otherwise return null.
*/
fun String.getDomainOrNull(context: Context): String? =
fun String.getDomainOrNull(resourceCacheManager: ResourceCacheManager): String? =
this
.toUriOrNull()
?.parseDomainOrNull(context = context)
?.parseDomainOrNull(resourceCacheManager = resourceCacheManager)
/**
* Extract the host with port from this [String] if possible, otherwise return null.
* Extract the host with optional port from this [String] if possible, otherwise return null.
*/
@OmitFromCoverage
fun String.getHostWithPortOrNull(): String? =
this
.toUriOrNull()
?.let { uri ->
val host = uri.host
val port = uri.port
if (host != null && port != -1) {
"$host:$port"
} else {
null
}
fun String.getHostWithPortOrNull(): String? {
val uri = this.toUriOrNull() ?: return null
return uri.host?.let { host ->
val port = uri.port
if (port != -1) {
"$host:$port"
} else {
host
}
}
}
/**
* Find the indices of the last occurrences of [substring] within this [String]. Return null if no

View File

@@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.platform.util
import android.content.Context
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.manager.ResourceCacheManager
import com.x8bit.bitwarden.data.platform.manager.model.DomainName
import java.net.URI
@@ -17,7 +16,7 @@ private const val IP_REGEX: String =
/**
* Parses the base domain from the URL. Returns null if unavailable.
*/
fun URI.parseDomainOrNull(context: Context): String? {
fun URI.parseDomainOrNull(resourceCacheManager: ResourceCacheManager): String? {
val host = this.host ?: return null
val isIpAddress = host.matches(IP_REGEX.toRegex())
@@ -25,7 +24,7 @@ fun URI.parseDomainOrNull(context: Context): String? {
host
} else {
parseDomainNameOrNullInternal(
context = context,
resourceCacheManager = resourceCacheManager,
host = host,
)
?.domain
@@ -35,13 +34,13 @@ fun URI.parseDomainOrNull(context: Context): String? {
/**
* Parses a URL to get the breakdown of a URL's domain. Returns null if invalid.
*/
fun URI.parseDomainNameOrNull(context: Context): DomainName? =
fun URI.parseDomainNameOrNull(resourceCacheManager: ResourceCacheManager): DomainName? =
this
// URI is a platform type and host can be null.
.host
?.let { nonNullHost ->
parseDomainNameOrNullInternal(
context = context,
resourceCacheManager = resourceCacheManager,
host = nonNullHost,
)
}
@@ -53,21 +52,12 @@ fun URI.parseDomainNameOrNull(context: Context): DomainName? =
*/
@Suppress("LongMethod")
private fun parseDomainNameOrNullInternal(
context: Context,
resourceCacheManager: ResourceCacheManager,
host: String,
): DomainName? {
val exceptionSuffixes = context
.resources
.getStringArray(R.array.exception_suffixes)
.toList()
val normalSuffixes = context
.resources
.getStringArray(R.array.normal_suffixes)
.toList()
val wildCardSuffixes = context
.resources
.getStringArray(R.array.wild_card_suffixes)
.toList()
val exceptionSuffixes = resourceCacheManager.domainExceptionSuffixes
val normalSuffixes = resourceCacheManager.domainNormalSuffixes
val wildCardSuffixes = resourceCacheManager.domainWildCardSuffixes
// Split the host into parts separated by a period. Start with the last part and incrementally
// add back the earlier parts to build a list of any matching domains in the data set.

View File

@@ -3,8 +3,8 @@ package com.x8bit.bitwarden.data.tools.generator.datasource.sdk
import com.bitwarden.generators.PassphraseGeneratorRequest
import com.bitwarden.generators.PasswordGeneratorRequest
import com.bitwarden.generators.UsernameGeneratorRequest
import com.bitwarden.sdk.Client
import com.bitwarden.sdk.ClientGenerators
import com.x8bit.bitwarden.data.platform.datasource.sdk.BaseSdkSource
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
/**
@@ -14,46 +14,43 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
* [ClientGenerators] provided by the Bitwarden SDK.
*/
class GeneratorSdkSourceImpl(
private val sdkClientManager: SdkClientManager,
) : GeneratorSdkSource {
sdkClientManager: SdkClientManager,
) : BaseSdkSource(sdkClientManager = sdkClientManager),
GeneratorSdkSource {
override suspend fun generatePassword(
request: PasswordGeneratorRequest,
): Result<String> = runCatching {
): Result<String> = runCatchingWithLogs {
getClient().generators().password(request)
}
override suspend fun generatePassphrase(
request: PassphraseGeneratorRequest,
): Result<String> = runCatching {
): Result<String> = runCatchingWithLogs {
getClient().generators().passphrase(request)
}
override suspend fun generatePlusAddressedEmail(
request: UsernameGeneratorRequest.Subaddress,
): Result<String> = runCatching {
): Result<String> = runCatchingWithLogs {
getClient().generators().username(request)
}
override suspend fun generateCatchAllEmail(
request: UsernameGeneratorRequest.Catchall,
): Result<String> = runCatching {
): Result<String> = runCatchingWithLogs {
getClient().generators().username(request)
}
override suspend fun generateRandomWord(
request: UsernameGeneratorRequest.Word,
): Result<String> = runCatching {
): Result<String> = runCatchingWithLogs {
getClient().generators().username(request)
}
override suspend fun generateForwardedServiceEmail(
request: UsernameGeneratorRequest.Forwarded,
): Result<String> = runCatching {
): Result<String> = runCatchingWithLogs {
getClient().generators().username(request)
}
private suspend fun getClient(
userId: String? = null,
): Client = sdkClientManager.getOrCreateClient(userId = userId)
}

View File

@@ -1,11 +1,14 @@
package com.x8bit.bitwarden.data.tools.generator.repository.model
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.platform.datasource.network.serializer.BaseEnumeratedIntSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* A data class representing the configuration options for both password and passphrase generation.
*
* @property type The type of passcode to be generated, as defined in PasscodeType.
* @property length The total length of the generated password.
* @property allowAmbiguousChar Indicates whether ambiguous characters are allowed in the password.
* @property hasNumbers Indicates whether the password should contain numbers.
@@ -23,6 +26,8 @@ import kotlinx.serialization.Serializable
*/
@Serializable
data class PasscodeGenerationOptions(
@SerialName("type")
val type: PasscodeType,
// Password-specific options
@@ -69,4 +74,22 @@ data class PasscodeGenerationOptions(
@SerialName("includeNumber")
val allowIncludeNumber: Boolean,
)
) {
/**
* Represents different Passcode types.
*/
@Serializable(with = PasscodeTypeSerializer::class)
enum class PasscodeType {
@SerialName("0")
PASSWORD,
@SerialName("1")
PASSPHRASE,
}
}
@Keep
private class PasscodeTypeSerializer :
BaseEnumeratedIntSerializer<PasscodeGenerationOptions.PasscodeType>(
PasscodeGenerationOptions.PasscodeType.entries.toTypedArray(),
)

View File

@@ -66,10 +66,16 @@ class VaultDiskSourceImpl(
ciphersDao
.getAllCiphers(userId = userId)
.map { entities ->
entities.map { entity ->
withContext(dispatcherManager.default) {
json.decodeFromString<SyncResponseJson.Cipher>(entity.cipherJson)
}
withContext(context = dispatcherManager.default) {
entities
.map { entity ->
async {
json.decodeFromString<SyncResponseJson.Cipher>(
string = entity.cipherJson,
)
}
}
.awaitAll()
}
},
)
@@ -180,10 +186,14 @@ class VaultDiskSourceImpl(
sendsDao
.getAllSends(userId = userId)
.map { entities ->
entities.map { entity ->
withContext(dispatcherManager.default) {
json.decodeFromString<SyncResponseJson.Send>(entity.sendJson)
}
withContext(context = dispatcherManager.default) {
entities
.map { entity ->
async {
json.decodeFromString<SyncResponseJson.Send>(entity.sendJson)
}
}
.awaitAll()
}
},
)

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