Compare commits

..

110 Commits

Author SHA1 Message Date
Dave Severns
17c579bfc2 PM-11387 on new create account with email verification, attempt login… (#3842) 2024-08-29 14:07:42 -04:00
Dave Severns
3c39d8beac PM-11224 Add menu to update feature flags with overridden values in real time (#3838) 2024-08-29 14:07:21 -04:00
Carlos Gonçalves
2a057bb1fb [PM-10762] Remove Passkey button should be hidden when I have Can View permission (#3829) 2024-08-29 16:40:01 +01:00
Patrick Honkonen
f778d7ecd1 [PM-10902] Base64 encode sensitive 2FA nav args (#3841) 2024-08-29 08:45:54 -04:00
Dave Severns
4c983525d3 PM-11310 handle email registration special circumstance after successful login (#3831) 2024-08-28 13:27:58 -04:00
Dave Severns
e32a9f303d PM-11394 String parse issue with app link (#3839) 2024-08-27 17:02:27 -04:00
David Perez
522e3bb939 PM-11354: TDE unlock since we already have the correct key from the identity service (#3835) 2024-08-27 08:54:51 -05:00
David Perez
0676cf8826 Update internal BitwardenTextButton padding to be consistent with all Bitwarden Buttons (#3834) 2024-08-26 17:10:02 -05:00
David Perez
5173dfd424 Carousel buttons should be full width (#3833) 2024-08-26 17:09:39 -05:00
David Perez
5bc31448b4 Update Firebase BOM to 33.2.0 (#3832) 2024-08-26 14:20:59 -05:00
David Perez
e9a7136a9a PM-10899: Only display the TDE UI if a selection has not yet been made (#3823) 2024-08-26 12:53:35 -05:00
David Perez
c36d0851ca Update the compose BOM (2024.08.00) (#3830) 2024-08-26 12:53:17 -05:00
Wu Nan
ace5f19375 [PM-11307] Fix typo in method name: shouldShouldRequestPermissionRationale (#3821) 2024-08-26 10:58:19 -04:00
A. Bubnov
88b40cfd10 [PM-10685] Support keyboard Done event as CTA Unlock on Pin\Master Password unlock screen (#3691) 2024-08-26 10:57:48 -04:00
David Perez
38e693f92c Simplify manual unlock check (#3824) 2024-08-26 09:07:23 -05:00
mpbw2
9dbb40f33b Add My Vault and Password Generator Quick Settings tiles (#3764) 2024-08-26 09:53:10 -04:00
Dave Severns
76a3265bbb PM-10692 pass a generated password back to the complete registration … (#3806) 2024-08-26 08:56:28 -04:00
David Perez
666c165b6f PM-10899: Fix user not being logged out properly on app restart (#3822) 2024-08-23 13:49:49 -05:00
Dave Severns
9db09c18cc [PM-11270] hide new UI in complete registration screen behind flag pt. 2 (#3812) 2024-08-23 12:52:21 -04:00
David Perez
b7330392cc PM-11299: Update the userState to properly parse the hasManageResetPasswordPermission flag (#3820) 2024-08-23 11:11:20 -05:00
David Perez
162da64567 Minor formatting an import cleanup (#3819) 2024-08-23 10:49:59 -05:00
github-actions[bot]
09f497ca9b Autosync Crowdin Translations (#3815) 2024-08-23 10:25:44 -04:00
André Bispo
87d7143cc8 [PM-6702] AppLink new redirect path (#3814) 2024-08-23 13:19:19 +00:00
David Perez
23bcfad717 PM-11273: Update the 'useKeyConnector' with 'keyConnectorEnabled' (#3813) 2024-08-22 17:05:46 -05:00
David Perez
f1f16cfee5 PM-11265: Remove the leave organization API (#3811) 2024-08-22 15:17:54 -05:00
Dave Severns
82d3b44712 [PM-11270] Hide all new UI behind onboarding flow flag. (#3810) 2024-08-22 16:06:54 -04:00
David Perez
b56a21b6e5 PM-10917: Fix crash caused when adding an item from a collection (#3809) 2024-08-22 13:48:46 -05:00
David Perez
eb2ba8e598 PM-11264: Ensure user has valid timeout action after migrating to Key Connector (#3807) 2024-08-22 13:48:26 -05:00
David Perez
91f039ecb6 Simplify common login helper methods (#3805) 2024-08-22 11:22:07 -05:00
Dave Severns
0d6aeee870 PM-10617 modify pw strength indicator to show min chars if required. (#3793) 2024-08-22 11:13:23 -04:00
David Perez
a0a5070ac7 PM-11254: Add logic for logging in with Key Connector (#3802) 2024-08-21 16:13:36 -05:00
David Perez
e7bd966e94 PM-11256: Add RootNav logic to display Remove Password Screen (#3803) 2024-08-21 15:53:27 -05:00
Dave Severns
075956ce17 PM-10617 + PM-10637 update complete registration screen to match new onboarding design (#3787) 2024-08-21 15:32:28 -04:00
David Perez
13b256d4e9 PM-11155: Add logic for handling remove password flow (#3801) 2024-08-21 14:30:27 -05:00
David Perez
5761e9510a PM-11248: Add isUsingKeyConnector flag to UserState (#3798) 2024-08-21 14:24:34 -05:00
David Perez
3b3b9ef33b Fix IllegalArgumentException in test (#3799) 2024-08-21 12:33:33 -05:00
David Perez
17fd3ec0f0 PM-11226: Wrap Key Connector APIs (#3794) 2024-08-21 12:26:20 -05:00
David Perez
43a6495b98 PM-11236: Add build type and flavor to the user agent (#3797) 2024-08-21 09:36:45 -05:00
Dave Severns
86dabea39f PM-11192 update check email screen to new design (#3788) 2024-08-20 18:17:03 -04:00
David Perez
8d08b5f7c5 PM-11223: Enable remote confg for email verification feature (#3792) 2024-08-20 15:19:23 -05:00
Matt Bishop
13c29c8296 Update public suffix list (#3790) 2024-08-20 14:18:00 -04:00
David Perez
eac5516a94 PM-11154: Create basic Remove Master Password UI (#3782) 2024-08-20 13:15:44 -05:00
renovate[bot]
88b674f54c [deps]: Lock file maintenance (#3786) 2024-08-20 12:27:51 -04:00
renovate[bot]
bcc24a2e25 [deps]: Update sonarsource/sonarcloud-github-action action to v3 (#3785) 2024-08-20 12:25:30 -04:00
renovate[bot]
e14f399e2d [deps]: Update github/codeql-action action to v3.26.3 (#3784) 2024-08-20 12:13:39 -04:00
André Bispo
ad2c575b39 [PM-9933] Update marketing copy (#3778) 2024-08-20 08:33:53 -04:00
Dave Severns
57c2e7ee4e [Pm 10616] create account start design (#3751) 2024-08-19 17:47:45 -04:00
Patrick Honkonen
55b57a605e [PM-10282] Update build artifact names (#3774) 2024-08-19 16:42:48 -04:00
David Perez
397c78b4af PM-11140: Update hasMasterPassword logic for key connectors (#3775) 2024-08-19 15:13:31 -05:00
David Perez
9e372c29d1 Update to the latest Bitwarden SDK (#3779) 2024-08-19 15:12:30 -05:00
David Perez
82fd7f01f8 PM-10954: Update the key connector APIs to use the correct url and responses (#3781) 2024-08-19 15:12:09 -05:00
Patrick Honkonen
a15b84a5bf [PM-10282] Default to last active account for passkey creation (#3780) 2024-08-19 15:10:31 -04:00
David Perez
5f46423638 Apply formatter to the app (#3777) 2024-08-19 13:43:45 -05:00
renovate[bot]
8aebd36465 [deps]: Update gradle minor (#3771) 2024-08-19 09:40:42 -04:00
renovate[bot]
b4f864d89c [deps]: Update kotlin (#3770) 2024-08-19 09:39:03 -04:00
Patrick Honkonen
8c8db78da6 [PM-10883] Support deserializing Forward Email service type details (#3739) 2024-08-19 09:02:57 -04:00
renovate[bot]
b18d9f53c6 [deps]: Lock file maintenance (#3772) 2024-08-19 12:59:25 +00:00
Dave Severns
7134d89352 PM-10986 explicitly keep AuthenticatedKeyConnectionApi to prevent cla… (#3765) 2024-08-16 15:45:03 -04:00
Patrick Honkonen
5a7dc198dd [PM-10884] Catch ProviderException when generating a secure key (#3733) 2024-08-16 15:13:41 -04:00
renovate[bot]
7dbfcfdea2 [deps]: Lock file maintenance (#3760) 2024-08-16 14:10:09 -04:00
renovate[bot]
b56ccd1bab [deps]: Update gradle/actions action to v4 (#3759) 2024-08-16 12:58:03 -04:00
renovate[bot]
f05828c87d [deps]: Update gh minor (#3758) 2024-08-16 12:31:59 -04:00
David Perez
48817f0fe4 Simplify error responses (#3762) 2024-08-16 15:07:56 +00:00
github-actions[bot]
3bed2581af Autosync Crowdin Translations (#3756) 2024-08-16 14:27:06 +00:00
André Bispo
acb125b2b9 [PM-6702] 6# Complete registration screen (#3622) 2024-08-16 15:16:36 +01:00
David Perez
72e5aedccd Rename APIs for extra specificity (#3755) 2024-08-16 09:04:10 -05:00
Shannon Draeker
9148a750a5 PM-10874: Prompt for biometrics after switching accounts (#3753) 2024-08-16 09:45:32 -04:00
David Perez
d4600c5c83 PM-10956: Add support for leave organization API (#3754) 2024-08-16 08:37:07 -05:00
David Perez
8094b3fd22 PM-10954: Add network APIs for key-connector (#3752) 2024-08-16 08:36:42 -05:00
David Perez
bd55b9ce72 Add helper function for static retrofit instances (#3749) 2024-08-15 15:26:12 -05:00
David Perez
4726cb743a PM-10936: Add account apis for key connectors (#3748) 2024-08-15 13:53:48 -05:00
André Bispo
244d259804 [PM-6702] 5# Check your email screen (#3621) 2024-08-15 18:25:45 +01:00
André Bispo
eab94dde79 [PM-6702] 4# Start registration screen (#3620) 2024-08-15 17:15:45 +01:00
David Perez
2bb921b592 All booleans stored are nullable for consistency (#3747) 2024-08-15 11:02:01 -05:00
David Perez
18b58e75f8 PM-10909: Add persistance layer for usersKeyConnector (#3740) 2024-08-15 10:34:30 -05:00
André Bispo
e2cd3867dd [PM-6702] 3# Open app from App Link to CompleteRegistration (#3619) 2024-08-15 14:28:35 +01:00
David Perez
524b9e9a08 Add logging for SDK functionality in debug only (#3738) 2024-08-14 16:10:19 -05:00
David Perez
4b35484abb Update to AGP 8.5.2 (#3736) 2024-08-14 15:33:03 -05:00
David Perez
d305dc3081 Remove unused dangerfile (#3735) 2024-08-14 15:32:36 -05:00
David Perez
dde90a251a Update WorkManager to 2.9.1 (#3737) 2024-08-14 15:32:13 -05:00
David Perez
516cd72f66 Fix a failing test (#3734) 2024-08-14 14:46:04 -05:00
David Perez
63884e8518 PM-10894: Add flag for disabling remote feature flag configuration (#3729) 2024-08-14 14:06:09 -05:00
David Perez
8a4d436f1f Remove API specific autofill configuration file (#3730) 2024-08-14 13:54:03 -05:00
Dave Severns
ab279e2264 PM-10851 make the default top app bar reactive (#3726) 2024-08-14 13:42:08 -04:00
Shannon Draeker
2876d75a21 PM-10874: Fix biometrics auto-prompt (#3728) 2024-08-14 11:48:58 -04:00
Patrick Honkonen
aaa0ce4ecd [PM-10664] Display server error message during 2FA login (#3719) 2024-08-14 11:30:05 -04:00
David Perez
499bc20850 PM-10878: Access parcelable data in a safe manor across SDK versions (#3727) 2024-08-14 10:28:01 -05:00
David Perez
2bed4986a1 PM-10855: Update the minimum SDK to API 29 (Android 10) (#3723) 2024-08-14 09:23:13 -05:00
Dave Severns
151b081161 PM-10619 screen to generate master password (#3721) 2024-08-13 16:58:51 -04:00
Shannon Draeker
e3371b7620 PM-8522: Fix vault tab nav bar title when logging in (#3710) 2024-08-13 12:55:51 -04:00
David Perez
551f948644 PM-10835: Make config request after environment update (#3720) 2024-08-13 11:34:33 -05:00
André Bispo
4bd81782c8 [PM-6702] 2# Region load in complete registration step (#3618) 2024-08-13 15:22:34 +01:00
Shannon Draeker
4dbcec85bb PM-10118: Remember generator types (#3708) 2024-08-13 09:27:54 -04:00
Patrick Honkonen
5a0b1caecd [PM-10696] Dismiss vault unlock keyboard (#3718) 2024-08-12 16:11:30 -04:00
Dave Severns
2b13151bd1 PM-10620 prevent account lockout tips screen (#3711) 2024-08-12 08:38:23 -04:00
David Perez
5e643e11fd PM-10243: Update carousel text (#3714) 2024-08-09 16:15:23 -05:00
Patrick Honkonen
2789b1cc37 [PM-10697] Auto-focus on PIN Dialog field (#3713) 2024-08-09 16:26:54 -04:00
David Perez
b7a47eb91e Add helper method for standardizing margins (#3712) 2024-08-09 14:59:21 -05:00
Dave Severns
06f6f19255 PM-10071 ensure that lowercase letters take priority over the upperca… (#3707) 2024-08-09 14:55:24 -04:00
André Bispo
e717183239 [PM-6702] 1# Add service calls for email verification (#3617) 2024-08-09 19:38:52 +01:00
David Perez
edb87202d2 PM-10628: Add pin unlock to SetupUnlockViewModel (#3709) 2024-08-09 12:09:52 -05:00
David Perez
9b808058f5 Allow the ShowShareSheet event to be launched after the screen is paused (#3706) 2024-08-09 09:58:47 -05:00
github-actions[bot]
89589aa907 Autosync Crowdin Translations (#3703)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2024-08-09 14:33:21 +00:00
David Perez
805fea630c Add logic for biometric unlock to SetupUnlockScreen (#3702) 2024-08-09 09:09:41 -05:00
David Perez
145f8adf0c PM-10621: Add the SetupUnlockScreen (#3699) 2024-08-08 16:18:29 -05:00
Dave Severns
6bb5ef7417 [PM-10618] MP guidance screen with info and clickable card to navigate … (#3697) 2024-08-08 16:53:56 -04:00
Carlos Gonçalves
722726882b [PM-9833] Allow passkey deletion edit view (#3654) 2024-08-08 21:17:09 +01:00
David Perez
9ed30d7913 Fix a minor parcelable warning (#3701) 2024-08-08 14:47:43 -05:00
David Perez
6c5c0c7c03 PM-10729: Add a helper method for determining if the app is in portrait orientation (#3698) 2024-08-08 12:24:12 -05:00
Dave Severns
a57a7e099c [PM-10065] Use appropriate back behavior depending on how you are take to auth approval screen (#3695) 2024-08-08 11:37:20 -04:00
386 changed files with 24132 additions and 3489 deletions

View File

@@ -40,7 +40,7 @@ jobs:
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/actions/wrapper-validation@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -62,7 +62,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
@@ -92,7 +92,7 @@ jobs:
strategy:
fail-fast: false
matrix:
variant: ["prod", "qa"]
variant: ["prod", "dev"]
artifact: ["apk", "aab"]
steps:
- name: Check out repo
@@ -150,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@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/actions/wrapper-validation@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -172,7 +172,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
@@ -230,14 +230,14 @@ jobs:
keyAlias:bitwarden-beta \
keyPassword:${{ env.PLAY_BETA_KEY_PASSWORD }}
- name: Generate QA Play Store APKs
- name: Generate debug Play Store APKs
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
run: |
bundle exec fastlane assembleDebugApks
- name: Upload release Play Store .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden.aab
path: app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden-standard-release.aab
@@ -245,7 +245,7 @@ jobs:
- name: Upload beta Play Store .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden.beta.aab
path: app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden-standard-beta.aab
@@ -253,7 +253,7 @@ jobs:
- name: Upload release .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden.apk
path: app/build/outputs/apk/standard/release/com.x8bit.bitwarden-standard-release.apk
@@ -261,18 +261,18 @@ jobs:
- name: Upload beta .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden.beta.apk
path: app/build/outputs/apk/standard/beta/com.x8bit.bitwarden-standard-beta.apk
if-no-files-found: error
# When building variants other than 'prod'
- name: Upload other .apk artifact
- name: Upload debug .apk artifact
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden-${{ matrix.variant }}.apk
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk
path: app/build/outputs/apk/standard/debug/com.x8bit.bitwarden-standard-debug.apk
if-no-files-found: error
@@ -280,70 +280,70 @@ jobs:
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/release/com.x8bit.bitwarden-standard-release.apk" \
> ./bw-android-apk-sha256.txt
> ./com.x8bit.bitwarden.apk-sha256.txt
- name: Create checksum for beta .apk artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/beta/com.x8bit.bitwarden-standard-beta.apk" \
> ./bw-android-beta-apk-sha256.txt
> ./com.x8bit.bitwarden.beta.apk-sha256.txt
- name: Create checksum for release .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
run: |
sha256sum "app/build/outputs/bundle/standardRelease/com.x8bit.bitwarden-standard-release.aab" \
> ./bw-android-aab-sha256.txt
> ./com.x8bit.bitwarden.aab-sha256.txt
- name: Create checksum for beta .aab artifact
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
run: |
sha256sum "app/build/outputs/bundle/standardBeta/com.x8bit.bitwarden-standard-beta.aab" \
> ./bw-android-beta-aab-sha256.txt
> ./com.x8bit.bitwarden.beta.aab-sha256.txt
- name: Create checksum for other .apk artifact
- name: Create checksum for Debug .apk artifact
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
run: |
sha256sum "app/build/outputs/apk/standard/debug/com.x8bit.bitwarden-standard-debug.apk" \
> ./bw-android-${{ matrix.variant }}-apk-sha256.txt
> ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
- name: Upload .apk SHA file for release
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: bw-android-apk-sha256.txt
path: ./bw-android-apk-sha256.txt
name: com.x8bit.bitwarden.apk-sha256.txt
path: ./com.x8bit.bitwarden.apk-sha256.txt
if-no-files-found: error
- name: Upload .apk SHA file for beta
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: bw-android-beta-apk-sha256.txt
path: ./bw-android-beta-apk-sha256.txt
name: com.x8bit.bitwarden.beta.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta.apk-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for release
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: bw-android-aab-sha256.txt
path: ./bw-android-aab-sha256.txt
name: com.x8bit.bitwarden.aab-sha256.txt
path: ./com.x8bit.bitwarden.aab-sha256.txt
if-no-files-found: error
- name: Upload .aab SHA file for beta
if: ${{ (matrix.variant == 'prod') && (matrix.artifact == 'aab') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: bw-android-beta-aab-sha256.txt
path: ./bw-android-beta-aab-sha256.txt
name: com.x8bit.bitwarden.beta.aab-sha256.txt
path: ./com.x8bit.bitwarden.beta.aab-sha256.txt
if-no-files-found: error
- name: Upload .apk SHA file for other
- name: Upload .apk SHA file for debug
if: ${{ (matrix.variant != 'prod') && (matrix.artifact == 'apk') }}
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: bw-android-${{ matrix.variant }}-apk-sha256.txt
path: ./bw-android-${{ matrix.variant }}-apk-sha256.txt
name: com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
path: ./com.x8bit.bitwarden.${{ matrix.variant }}.apk-sha256.txt
if-no-files-found: error
- name: Install Firebase app distribution plugin
@@ -424,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@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/actions/wrapper-validation@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -446,7 +446,7 @@ jobs:
${{ runner.os }}-build-
- name: Configure JDK
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}
@@ -469,7 +469,6 @@ jobs:
keyAlias:bitwarden \
keyPassword:"${{ env.FDROID_STORE_PASSWORD }}"
# Generate the F-Droid APK for publishing
- name: Generate F-Droid Beta Artifacts
env:
FDROID_BETA_KEYSTORE_PASSWORD: ${{ secrets.FDROID_BETA_KEYSTORE_PASSWORD }}
@@ -482,7 +481,7 @@ jobs:
keyPassword:"${{ env.FDROID_BETA_KEY_PASSWORD }}"
- name: Upload F-Droid .apk artifact
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden-fdroid.apk
path: app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid-release.apk
@@ -491,32 +490,32 @@ jobs:
- name: Create checksum for F-Droid artifact
run: |
sha256sum "app/build/outputs/apk/fdroid/release/com.x8bit.bitwarden-fdroid-release.apk" \
> ./bw-fdroid-apk-sha256.txt
> ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
- name: Upload F-Droid SHA file
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: bw-fdroid-apk-sha256.txt
path: ./bw-fdroid-apk-sha256.txt
name: com.x8bit.bitwarden-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden-fdroid.apk-sha256.txt
if-no-files-found: error
- name: Upload F-Droid Beta .apk artifact
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: com.x8bit.bitwarden-fdroid-beta.apk
name: com.x8bit.bitwarden.beta-fdroid.apk
path: app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden-fdroid-beta.apk
if-no-files-found: error
- name: Create checksum for F-Droid Beta artifact
run: |
sha256sum "app/build/outputs/apk/fdroid/beta/com.x8bit.bitwarden-fdroid-beta.apk" \
> ./bw-fdroid-beta-apk-sha256.txt
> ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
- name: Upload F-Droid Beta SHA file
uses: actions/upload-artifact@89ef406dd8d7e03cfd12d9e0a4a378f454709029 # v4.3.5
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
with:
name: bw-fdroid-beta-apk-sha256.txt
path: ./bw-fdroid-beta-apk-sha256.txt
name: com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
path: ./com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt
if-no-files-found: error
- name: Install Firebase app distribution plugin

View File

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

View File

@@ -36,7 +36,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@d9c87d481d55275bb5441eef3fe0e46805f9ef70 # v3.5.0
uses: gradle/actions/wrapper-validation@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0
- name: Cache Gradle files
uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2
@@ -63,7 +63,7 @@ jobs:
bundler-cache: true
- name: Configure JDK
uses: actions/setup-java@99b8673ff64fbf99d8d325f52d9a5bdedb8483e9 # v4.2.1
uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
with:
distribution: "temurin"
java-version: ${{ env.JAVA_VERSION }}

View File

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

View File

@@ -10,16 +10,16 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.3.0)
aws-partitions (1.961.0)
aws-sdk-core (3.201.3)
aws-partitions (1.966.0)
aws-sdk-core (3.201.5)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
aws-sigv4 (~> 1.9)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.88.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.157.0)
aws-sdk-s3 (1.158.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -134,7 +134,7 @@ GEM
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.7.0)
google-cloud-core (1.7.1)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
@@ -155,7 +155,7 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.6)
http-cookie (1.0.7)
domain_name (~> 0.5)
httpclient (2.8.3)
jmespath (1.6.2)
@@ -179,7 +179,7 @@ GEM
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.2.9)
rexml (3.3.5)
strscan
rouge (2.0.7)
ruby2_keywords (0.0.5)
@@ -207,13 +207,13 @@ GEM
uber (0.1.0)
unicode-display_width (2.5.0)
word_wrap (1.0.0)
xcodeproj (1.24.0)
xcodeproj (1.25.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.3.0)
rexml (~> 3.2.4)
rexml (>= 3.3.2, < 4.0)
xcpretty (0.3.0)
rouge (~> 2.0.7)
xcpretty-travis-formatter (1.0.1)

View File

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

@@ -61,6 +61,8 @@ android {
signingConfig = signingConfigs.getByName("debug")
isDebuggable = true
isMinifyEnabled = false
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "true")
}
// Beta and Release variants are identical except beta has a different package name
@@ -72,6 +74,8 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false")
}
release {
isDebuggable = false
@@ -80,6 +84,8 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField(type = "boolean", name = "HAS_DEBUG_MENU", value = "false")
}
}

View File

@@ -55,10 +55,24 @@
<data android:mimeType="video/*" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="vault.bitwarden.com" />
<data android:host="vault.bitwarden.eu" />
<data android:host="*.bitwarden.pw" />
<data android:pathPattern="/redirect-connector.*" />
</intent-filter>
<intent-filter>
<action android:name="com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY" />
<action android:name="com.x8bit.bitwarden.fido2.ACTION_GET_PASSKEY" />
<action android:name="com.x8bit.bitwarden.fido2.ACTION_UNLOCK_ACCOUNT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
@@ -178,6 +192,42 @@
android:value="true" />
</service>
<!--
The GeneratorTileService name below refers to the legacy Xamarin app's service name.
This must always match in order for the app to properly query if it is providing generator
tile services.
-->
<!--suppress AndroidDomInspection -->
<service
android:name="com.x8bit.bitwarden.GeneratorTileService"
android:exported="true"
android:icon="@drawable/ic_generator"
android:label="@string/password_generator"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<!--
The MyVaultTileService name below refers to the legacy Xamarin app's service name.
This must always match in order for the app to properly query if it is providing vault
tile services.
-->
<!--suppress AndroidDomInspection -->
<service
android:name="com.x8bit.bitwarden.MyVaultTileService"
android:exported="true"
android:icon="@drawable/ic_notification"
android:label="@string/my_vault"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:ignore="MissingClass">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<meta-data
android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />

View File

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

View File

@@ -7,7 +7,6 @@ import com.x8bit.bitwarden.data.platform.manager.CrashLogsManager
import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManager
import com.x8bit.bitwarden.data.platform.manager.event.OrganizationEventManager
import com.x8bit.bitwarden.data.platform.manager.restriction.RestrictionManager
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@@ -33,7 +32,4 @@ class BitwardenApplication : Application() {
@Inject
lateinit var restrictionManager: RestrictionManager
@Inject
lateinit var serverConfigRepository: ServerConfigRepository
}

View File

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

View File

@@ -6,6 +6,7 @@ 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
@@ -34,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"
@@ -53,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,
@@ -137,9 +140,14 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.VaultUnlockStateChange -> handleVaultUnlockStateChange()
is MainAction.ReceiveFirstIntent -> handleFirstIntentReceived(action)
is MainAction.ReceiveNewIntent -> handleNewIntentReceived(action)
MainAction.OpenDebugMenu -> handleOpenDebugMenu()
}
}
private fun handleOpenDebugMenu() {
sendEvent(MainEvent.NavigateToDebugMenu)
}
private fun handleAutofillSelectionReceive(
action: MainAction.Internal.AutofillSelectionReceive,
) {
@@ -188,6 +196,7 @@ 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 {
@@ -201,6 +210,17 @@ class MainViewModel @Inject constructor(
)
}
completeRegistrationData != null -> {
if (authRepository.activeUserId != null) {
authRepository.hasPendingAccountAddition = true
}
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.PreLogin.CompleteRegistration(
completeRegistrationData = completeRegistrationData,
timestamp = clock.millis(),
)
}
autofillSaveItem != null -> {
specialCircumstanceManager.specialCircumstance =
SpecialCircumstance.AutofillSave(
@@ -300,6 +320,11 @@ sealed class MainAction {
*/
data class ReceiveNewIntent(val intent: Intent) : MainAction()
/**
* Receive event to open the debug menu.
*/
data object OpenDebugMenu : MainAction()
/**
* Actions for internal use by the ViewModel.
*/
@@ -351,4 +376,9 @@ sealed class MainEvent {
* Event indicating that the UI should recreate itself.
*/
data object Recreate : MainEvent()
/**
* Navigate to the debug menu.
*/
data object NavigateToDebugMenu : MainEvent()
}

View File

@@ -45,12 +45,37 @@ interface AuthDiskSource {
*/
fun clearData(userId: String)
/**
* Retrieves the state indicating that the user should use a key connector.
*/
fun getShouldUseKeyConnector(userId: String): Boolean?
/**
* Retrieves the state indicating that the user should use a key connector as a flow.
*/
fun getShouldUseKeyConnectorFlow(userId: String): Flow<Boolean?>
/**
* Stores the boolean indicating that the user should use a key connector.
*/
fun storeShouldUseKeyConnector(userId: String, shouldUseKeyConnector: Boolean?)
/**
* Retrieves the state indicating that the user has completed login with TDE.
*/
fun getIsTdeLoginComplete(userId: String): Boolean?
/**
* Stores the boolean indicating that the user has completed login with TDE.
*/
fun storeIsTdeLoginComplete(userId: String, isTdeLoginComplete: Boolean?)
/**
* Retrieves the state indicating that the user has chosen to trust this device.
*
* Note: This indicates intent to trust the device, the device may not be trusted yet.
*/
fun getShouldTrustDevice(userId: String): Boolean
fun getShouldTrustDevice(userId: String): Boolean?
/**
* Stores the boolean indicating that the user has chosen to trust this device for the given

View File

@@ -39,6 +39,8 @@ private const val TWO_FACTOR_TOKEN_KEY = "twoFactorToken"
private const val MASTER_PASSWORD_HASH_KEY = "keyHash"
private const val POLICIES_KEY = "policies"
private const val SHOULD_TRUST_DEVICE_KEY = "shouldTrustDevice"
private const val TDE_LOGIN_COMPLETE = "tdeLoginComplete"
private const val USES_KEY_CONNECTOR = "usesKeyConnector"
/**
* Primary implementation of [AuthDiskSource].
@@ -56,6 +58,8 @@ class AuthDiskSourceImpl(
AuthDiskSource {
private val inMemoryPinProtectedUserKeys = mutableMapOf<String, String?>()
private val mutableShouldUseKeyConnectorFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableOrganizationsFlowMap =
mutableMapOf<String, MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?>>()
private val mutablePoliciesFlowMap =
@@ -122,15 +126,40 @@ class AuthDiskSourceImpl(
storeMasterPasswordHash(userId = userId, passwordHash = null)
storePolicies(userId = userId, policies = null)
storeAccountTokens(userId = userId, accountTokens = null)
storeShouldUseKeyConnector(userId = userId, shouldUseKeyConnector = null)
storeIsTdeLoginComplete(userId = userId, isTdeLoginComplete = null)
// Do not remove the DeviceKey or PendingAuthRequest on logout, these are persisted
// indefinitely unless the TDE flow explicitly removes them.
}
override fun getShouldTrustDevice(userId: String): Boolean =
requireNotNull(
getBoolean(key = SHOULD_TRUST_DEVICE_KEY.appendIdentifier(userId), default = false),
override fun getShouldUseKeyConnectorFlow(userId: String): Flow<Boolean?> =
getMutableShouldUseKeyConnectorFlowMap(userId = userId)
.onSubscription { emit(getShouldUseKeyConnector(userId = userId)) }
override fun getShouldUseKeyConnector(
userId: String,
): Boolean? = getBoolean(key = USES_KEY_CONNECTOR.appendIdentifier(userId))
override fun storeShouldUseKeyConnector(userId: String, shouldUseKeyConnector: Boolean?) {
putBoolean(
key = USES_KEY_CONNECTOR.appendIdentifier(userId),
value = shouldUseKeyConnector,
)
getMutableShouldUseKeyConnectorFlowMap(userId = userId).tryEmit(shouldUseKeyConnector)
}
override fun getIsTdeLoginComplete(
userId: String,
): Boolean? = getBoolean(key = TDE_LOGIN_COMPLETE.appendIdentifier(userId))
override fun storeIsTdeLoginComplete(userId: String, isTdeLoginComplete: Boolean?) {
putBoolean(TDE_LOGIN_COMPLETE.appendIdentifier(userId), isTdeLoginComplete)
}
override fun getShouldTrustDevice(
userId: String,
): Boolean? = getBoolean(key = SHOULD_TRUST_DEVICE_KEY.appendIdentifier(userId))
override fun storeShouldTrustDevice(userId: String, shouldTrustDevice: Boolean?) {
putBoolean(SHOULD_TRUST_DEVICE_KEY.appendIdentifier(userId), shouldTrustDevice)
@@ -369,6 +398,13 @@ class AuthDiskSourceImpl(
putString(key = UNIQUE_APP_ID_KEY, value = it)
}
private fun getMutableShouldUseKeyConnectorFlowMap(
userId: String,
): MutableSharedFlow<Boolean?> =
mutableShouldUseKeyConnectorFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableOrganizationsFlow(
userId: String,
): MutableSharedFlow<List<SyncResponseJson.Profile.Organization>?> =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,9 +16,11 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserState
@@ -101,6 +103,11 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
*/
var rememberedOrgIdentifier: String?
/**
* The currently persisted state indicating whether the user has completed login via TDE.
*/
val tdeLoginComplete: Boolean?
/**
* The currently persisted state indicating whether the user has trusted this device.
*/
@@ -253,6 +260,7 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
email: String,
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String? = null,
captchaToken: String?,
shouldCheckDataBreaches: Boolean,
isMasterPasswordStrong: Boolean,
@@ -265,6 +273,12 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
email: String,
): PasswordHintResult
/**
* Removes the users password from the account. This used used when migrating from master
* password login to key connector login.
*/
suspend fun removePassword(masterPassword: String): RemovePasswordResult
/**
* Resets the users password from the [currentPassword] (or null for account recovery resets),
* to the [newPassword] and optional [passwordHint].
@@ -354,4 +368,13 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager {
* 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

@@ -16,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
@@ -33,6 +35,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toInt
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.model.AuthState
@@ -47,12 +50,15 @@ import com.x8bit.bitwarden.data.auth.repository.model.PasswordStrengthResult
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.model.PrevalidateSsoResult
import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult
import com.x8bit.bitwarden.data.auth.repository.model.RemovePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.RequestOtpResult
import com.x8bit.bitwarden.data.auth.repository.model.ResendEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.ResetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SendVerificationEmailResult
import com.x8bit.bitwarden.data.auth.repository.model.SetPasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
import com.x8bit.bitwarden.data.auth.repository.model.UserKeyConnectorState
import com.x8bit.bitwarden.data.auth.repository.model.UserOrganizations
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
@@ -66,11 +72,14 @@ import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
import com.x8bit.bitwarden.data.auth.repository.util.toUserStateJsonWithPassword
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokensFlow
import com.x8bit.bitwarden.data.auth.repository.util.userKeyConnectorStateFlow
import com.x8bit.bitwarden.data.auth.repository.util.userKeyConnectorStateList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsList
import com.x8bit.bitwarden.data.auth.repository.util.userOrganizationsListFlow
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
@@ -89,6 +98,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl
import com.x8bit.bitwarden.data.platform.util.asFailure
import com.x8bit.bitwarden.data.platform.util.asSuccess
import com.x8bit.bitwarden.data.platform.util.flatMap
import com.x8bit.bitwarden.data.vault.datasource.network.model.OrganizationType
import com.x8bit.bitwarden.data.vault.datasource.network.model.PolicyTypeJson
import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
@@ -139,6 +149,7 @@ class AuthRepositoryImpl(
private val settingsRepository: SettingsRepository,
private val vaultRepository: VaultRepository,
private val authRequestManager: AuthRequestManager,
private val keyConnectorManager: KeyConnectorManager,
private val trustedDeviceManager: TrustedDeviceManager,
private val userLogoutManager: UserLogoutManager,
private val policyManager: PolicyManager,
@@ -235,6 +246,7 @@ class AuthRepositoryImpl(
authDiskSource.userStateFlow,
authDiskSource.userAccountTokensFlow,
authDiskSource.userOrganizationsListFlow,
authDiskSource.userKeyConnectorStateFlow,
vaultRepository.vaultUnlockDataStateFlow,
mutableHasPendingAccountAdditionStateFlow,
// Ignore the data in the merge, but trigger an update when they emit.
@@ -246,12 +258,14 @@ class AuthRepositoryImpl(
val userStateJson = array[0] as UserStateJson?
val userAccountTokens = array[1] as List<UserAccountTokens>
val userOrganizationsList = array[2] as List<UserOrganizations>
val vaultState = array[3] as List<VaultUnlockData>
val hasPendingAccountAddition = array[4] as Boolean
val userIsUsingKeyConnectorList = array[3] as List<UserKeyConnectorState>
val vaultState = array[4] as List<VaultUnlockData>
val hasPendingAccountAddition = array[5] as Boolean
userStateJson?.toUserState(
vaultState = vaultState,
userAccountTokens = userAccountTokens,
userOrganizationsList = userOrganizationsList,
userIsUsingKeyConnectorList = userIsUsingKeyConnectorList,
hasPendingAccountAddition = hasPendingAccountAddition,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
@@ -269,6 +283,7 @@ class AuthRepositoryImpl(
vaultState = vaultRepository.vaultUnlockDataStateFlow.value,
userAccountTokens = authDiskSource.userAccountTokens,
userOrganizationsList = authDiskSource.userOrganizationsList,
userIsUsingKeyConnectorList = authDiskSource.userKeyConnectorStateList,
hasPendingAccountAddition = mutableHasPendingAccountAdditionStateFlow.value,
isBiometricsEnabledProvider = ::isBiometricsEnabled,
vaultUnlockTypeProvider = ::getVaultUnlockType,
@@ -297,6 +312,9 @@ class AuthRepositoryImpl(
override var rememberedOrgIdentifier: String? by authDiskSource::rememberedOrgIdentifier
override val tdeLoginComplete: Boolean?
get() = activeUserId?.let { authDiskSource.getIsTdeLoginComplete(userId = it) }
override var shouldTrustDevice: Boolean
get() = activeUserId?.let { authDiskSource.getShouldTrustDevice(userId = it) } ?: false
set(value) {
@@ -467,7 +485,8 @@ class AuthRepositoryImpl(
userId = userId,
email = account.profile.email,
orgPublicKey = organizationKeys.publicKey,
rememberDevice = authDiskSource.getShouldTrustDevice(userId = userId),
rememberDevice = authDiskSource
.getShouldTrustDevice(userId = userId) == true,
)
}
.flatMap { keys ->
@@ -535,7 +554,6 @@ class AuthRepositoryImpl(
)
}
authDiskSource.storeUserKey(userId = userId, userKey = asymmetricalKey)
vaultRepository.syncIfNecessary()
return LoginResult.Success
}
@@ -723,6 +741,7 @@ class AuthRepositoryImpl(
email: String,
masterPassword: String,
masterPasswordHint: String?,
emailVerificationToken: String?,
captchaToken: String?,
shouldCheckDataBreaches: Boolean,
isMasterPasswordStrong: Boolean,
@@ -751,21 +770,41 @@ class AuthRepositoryImpl(
kdf = kdf,
)
.flatMap { registerKeyResponse ->
identityService.register(
body = RegisterRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
captchaResponse = captchaToken,
key = registerKeyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
if (emailVerificationToken == null) {
// TODO PM-6675: Remove register call and service implementation
identityService.register(
body = RegisterRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
captchaResponse = captchaToken,
key = registerKeyResponse.encryptedUserKey,
keys = RegisterRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
)
)
} else {
identityService.registerFinish(
body = RegisterFinishRequestJson(
email = email,
masterPasswordHash = registerKeyResponse.masterPasswordHash,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
captchaResponse = captchaToken,
userSymmetricKey = registerKeyResponse.encryptedUserKey,
userAsymmetricKeys = RegisterFinishRequestJson.Keys(
publicKey = registerKeyResponse.keys.public,
encryptedPrivateKey = registerKeyResponse.keys.private,
),
kdfType = kdf.toKdfTypeJson(),
kdfIterations = kdf.iterations,
),
)
}
}
.fold(
onSuccess = {
@@ -791,10 +830,6 @@ class AuthRepositoryImpl(
?: it.message,
)
}
is RegisterResponseJson.Error -> {
RegisterResult.Error(it.message)
}
}
},
onFailure = { RegisterResult.Error(errorMessage = null) },
@@ -813,6 +848,46 @@ class AuthRepositoryImpl(
)
}
override suspend fun removePassword(masterPassword: String): RemovePasswordResult {
val activeAccount = authDiskSource
.userState
?.activeAccount
?: return RemovePasswordResult.Error
val profile = activeAccount.profile
val userId = profile.userId
val userKey = authDiskSource
.getUserKey(userId = userId)
?: return RemovePasswordResult.Error
val keyConnectorUrl = organizations
.find {
it.shouldUseKeyConnector &&
it.type != OrganizationType.OWNER &&
it.type != OrganizationType.ADMIN
}
?.keyConnectorUrl
?: return RemovePasswordResult.Error
return keyConnectorManager
.migrateExistingUserToKeyConnector(
userId = userId,
url = keyConnectorUrl,
userKeyEncrypted = userKey,
email = profile.email,
masterPassword = masterPassword,
kdf = profile.toSdkParams(),
)
.onSuccess {
authDiskSource.userState = authDiskSource
.userState
?.toRemovedPasswordUserStateJson(userId = userId)
vaultRepository.sync()
settingsRepository.setDefaultsIfNecessary(userId = userId)
}
.fold(
onFailure = { RemovePasswordResult.Error },
onSuccess = { RemovePasswordResult.Success },
)
}
override suspend fun resetPassword(
currentPassword: String?,
newPassword: String,
@@ -1159,6 +1234,28 @@ class AuthRepositoryImpl(
): 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,
@@ -1367,30 +1464,42 @@ class AuthRepositoryImpl(
previousUserState = authDiskSource.userState,
environmentUrlData = environmentRepository.environment.environmentUrlData,
)
val userId = userStateJson.activeUserId
val profile = userStateJson.activeAccount.profile
val userId = profile.userId
checkForVaultUnlockError(
onVaultUnlockError = { vaultUnlockError ->
return@userStateTransaction vaultUnlockError.toLoginErrorResult()
},
) {
val keyConnectorUrl = loginResponse
.keyConnectorUrl
?: loginResponse
.userDecryptionOptions
?.keyConnectorUserDecryptionOptions
?.keyConnectorUrl
val isDeviceUnlockAvailable = deviceData != null ||
loginResponse.userDecryptionOptions?.trustedDeviceUserDecryptionOptions != null
// if possible attempt to unlock the vault with trusted device data
if (isDeviceUnlockAvailable) {
unlockVaultWithTdeOnLoginSuccess(
loginResponse = loginResponse,
userStateJson = userStateJson,
profile = profile,
deviceData = deviceData,
)
} else if (keyConnectorUrl != null && orgIdentifier != null) {
unlockVaultWithKeyConnectorOnLoginSuccess(
profile = profile,
keyConnectorUrl = keyConnectorUrl,
orgIdentifier = orgIdentifier,
loginResponse = loginResponse,
)
} else {
password?.let {
unlockVaultWithPasswordOnLoginSuccess(
loginResponse = loginResponse,
userStateJson = userStateJson,
password = it,
)
}
unlockVaultWithPasswordOnLoginSuccess(
loginResponse = loginResponse,
profile = profile,
password = password,
)
}
}
@@ -1400,7 +1509,7 @@ class AuthRepositoryImpl(
.hashPassword(
email = email,
password = it,
kdf = userStateJson.activeAccount.profile.toSdkParams(),
kdf = profile.toSdkParams(),
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
.onSuccess { passwordHash ->
@@ -1428,8 +1537,11 @@ class AuthRepositoryImpl(
// when we completed the pending admin auth request.
authDiskSource.storeUserKey(userId = userId, userKey = it)
}
authDiskSource.storePrivateKey(userId = userId, privateKey = loginResponse.privateKey)
loginResponse.privateKey?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the key connector conversion.
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
}
// If the user just authenticated with a two-factor code and selected the option to
// remember it, then the API response will return a token that will be used in place
// of the two-factor code on the next login attempt.
@@ -1478,12 +1590,89 @@ class AuthRepositoryImpl(
return LoginResult.TwoFactorRequired
}
/**
* Attempt to unlock the current user's vault with key connector data.
*/
private suspend fun unlockVaultWithKeyConnectorOnLoginSuccess(
profile: AccountJson.Profile,
keyConnectorUrl: String,
orgIdentifier: String,
loginResponse: GetTokenResponseJson.Success,
): VaultUnlockResult? =
if (loginResponse.userDecryptionOptions?.hasMasterPassword != false) {
// This user has a master password, so we skip the key-connector logic as it is not
// setup yet. The user can still unlock the vault with their master password.
null
} else if (loginResponse.key != null && loginResponse.privateKey != null) {
// This is a returning user who should already have the key connector setup
keyConnectorManager
.getMasterKeyFromKeyConnector(
url = keyConnectorUrl,
accessToken = loginResponse.accessToken,
)
.map {
unlockVault(
accountProfile = profile,
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = it.masterKey,
userKey = loginResponse.key,
),
)
}
.fold(
// If the request failed, we want to abort the login process
onFailure = { VaultUnlockResult.GenericError },
onSuccess = { it },
)
} else {
// This is a new user who needs to setup the key connector
keyConnectorManager
.migrateNewUserToKeyConnector(
url = keyConnectorUrl,
accessToken = loginResponse.accessToken,
kdfType = loginResponse.kdfType,
kdfIterations = loginResponse.kdfIterations,
kdfMemory = loginResponse.kdfMemory,
kdfParallelism = loginResponse.kdfParallelism,
organizationIdentifier = orgIdentifier,
)
.map { keyConnectorResponse ->
val result = unlockVault(
accountProfile = profile,
privateKey = keyConnectorResponse.keys.private,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = keyConnectorResponse.masterKey,
userKey = keyConnectorResponse.encryptedUserKey,
),
)
if (result is VaultUnlockResult.Success) {
// We now know that login/unlock was successful, so we store the userKey
// and privateKey we now have since it didn't exist on the loginResponse
authDiskSource.storeUserKey(
userId = profile.userId,
userKey = keyConnectorResponse.encryptedUserKey,
)
authDiskSource.storePrivateKey(
userId = profile.userId,
privateKey = keyConnectorResponse.keys.private,
)
}
result
}
.fold(
// If the request failed, we want to abort the login process
onFailure = { VaultUnlockResult.GenericError },
onSuccess = { it },
)
}
/**
* Attempt to unlock the current user's vault with password data.
*/
private suspend fun unlockVaultWithPasswordOnLoginSuccess(
loginResponse: GetTokenResponseJson.Success,
userStateJson: UserStateJson,
profile: AccountJson.Profile,
password: String?,
): VaultUnlockResult? {
// Attempt to unlock the vault with password if possible.
@@ -1491,7 +1680,7 @@ class AuthRepositoryImpl(
val privateKey = loginResponse.privateKey ?: return null
val key = loginResponse.key ?: return null
return unlockVault(
accountProfile = userStateJson.activeAccount.profile,
accountProfile = profile,
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.Password(
password = masterPassword,
@@ -1505,7 +1694,7 @@ class AuthRepositoryImpl(
*/
private suspend fun unlockVaultWithTdeOnLoginSuccess(
loginResponse: GetTokenResponseJson.Success,
userStateJson: UserStateJson,
profile: AccountJson.Profile,
deviceData: DeviceDataModel?,
): VaultUnlockResult? {
// Attempt to unlock the vault with auth request if possible.
@@ -1513,7 +1702,7 @@ class AuthRepositoryImpl(
if (loginResponse.privateKey != null && loginResponse.key != null) {
deviceData?.let { model ->
return unlockVault(
accountProfile = userStateJson.activeAccount.profile,
accountProfile = profile,
privateKey = loginResponse.privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = model.privateKey,
@@ -1542,7 +1731,7 @@ class AuthRepositoryImpl(
loginResponse.privateKey?.let { privateKey ->
unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options = options,
userStateJson = userStateJson,
profile = profile,
privateKey = privateKey,
)
}
@@ -1555,11 +1744,11 @@ class AuthRepositoryImpl(
*/
private suspend fun unlockVaultWithTrustedDeviceUserDecryptionOptionsAndStoreKeys(
options: TrustedDeviceUserDecryptionOptionsJson,
userStateJson: UserStateJson,
profile: AccountJson.Profile,
privateKey: String,
): VaultUnlockResult? {
var vaultUnlockResult: VaultUnlockResult? = null
val userId = userStateJson.activeUserId
val userId = profile.userId
val deviceKey = authDiskSource.getDeviceKey(userId = userId)
if (deviceKey == null) {
// A null device key means this device is not trusted.
@@ -1573,7 +1762,7 @@ class AuthRepositoryImpl(
// For approved requests the key will always be present.
val userKey = requireNotNull(request.key)
vaultUnlockResult = unlockVault(
accountProfile = userStateJson.activeAccount.profile,
accountProfile = profile,
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = pendingRequest.requestPrivateKey,
@@ -1600,7 +1789,7 @@ class AuthRepositoryImpl(
}
vaultUnlockResult = unlockVault(
accountProfile = userStateJson.activeAccount.profile,
accountProfile = profile,
privateKey = privateKey,
initUserCryptoMethod = InitUserCryptoMethod.DeviceKey(
deviceKey = deviceKey,

View File

@@ -8,6 +8,7 @@ import com.x8bit.bitwarden.data.auth.datasource.network.service.IdentityService
import com.x8bit.bitwarden.data.auth.datasource.network.service.OrganizationService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.AuthRequestManager
import com.x8bit.bitwarden.data.auth.manager.KeyConnectorManager
import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
@@ -48,6 +49,7 @@ object AuthRepositoryModule {
environmentRepository: EnvironmentRepository,
settingsRepository: SettingsRepository,
vaultRepository: VaultRepository,
keyConnectorManager: KeyConnectorManager,
authRequestManager: AuthRequestManager,
trustedDeviceManager: TrustedDeviceManager,
userLogoutManager: UserLogoutManager,
@@ -67,6 +69,7 @@ object AuthRepositoryModule {
environmentRepository = environmentRepository,
settingsRepository = settingsRepository,
vaultRepository = vaultRepository,
keyConnectorManager = keyConnectorManager,
authRequestManager = authRequestManager,
trustedDeviceManager = trustedDeviceManager,
userLogoutManager = userLogoutManager,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -21,6 +21,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import java.time.Clock
import javax.inject.Singleton
/**
@@ -41,6 +42,7 @@ object Fido2ProviderModule {
fido2CredentialManager: Fido2CredentialManager,
dispatcherManager: DispatcherManager,
intentManager: IntentManager,
clock: Clock,
): Fido2ProviderProcessor =
Fido2ProviderProcessorImpl(
context,
@@ -49,6 +51,7 @@ object Fido2ProviderModule {
fido2CredentialStore,
fido2CredentialManager,
intentManager,
clock,
dispatcherManager,
)

View File

@@ -55,18 +55,18 @@ class Fido2CredentialManagerImpl(
selectedCipherView: CipherView,
): Fido2RegisterCredentialResult {
val clientData = if (fido2CredentialRequest.callingAppInfo.isOriginPopulated()) {
fido2CredentialRequest
fido2CredentialRequest
.callingAppInfo
.getAppSigningSignatureFingerprint()
?.let { ClientData.DefaultWithCustomHash(hash = it) }
?: return Fido2RegisterCredentialResult.Error
} else {
ClientData.DefaultWithExtraData(
androidPackageName = fido2CredentialRequest
.callingAppInfo
.getAppSigningSignatureFingerprint()
?.let { ClientData.DefaultWithCustomHash(hash = it) }
?: return Fido2RegisterCredentialResult.Error
} else {
ClientData.DefaultWithExtraData(
androidPackageName = fido2CredentialRequest
.callingAppInfo
.packageName,
)
}
.packageName,
)
}
val origin = fido2CredentialRequest
.origin
?: getOriginUrlFromAttestationOptionsOrNull(fido2CredentialRequest.requestJson)

View File

@@ -38,6 +38,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DecryptFido2CredentialAut
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.time.Clock
import java.util.concurrent.atomic.AtomicInteger
private const val CREATE_PASSKEY_INTENT = "com.x8bit.bitwarden.fido2.ACTION_CREATE_PASSKEY"
@@ -57,6 +58,7 @@ class Fido2ProviderProcessorImpl(
private val fido2CredentialStore: Fido2CredentialStore,
private val fido2CredentialManager: Fido2CredentialManager,
private val intentManager: IntentManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
) : Fido2ProviderProcessor {
@@ -111,13 +113,14 @@ class Fido2ProviderProcessorImpl(
val userState = authRepository.userStateFlow.value ?: return null
return BeginCreateCredentialResponse.Builder()
.setCreateEntries(userState.accounts.toCreateEntries())
.setCreateEntries(userState.accounts.toCreateEntries(userState.activeUserId))
.build()
}
private fun List<UserState.Account>.toCreateEntries() = map { it.toCreateEntry() }
private fun List<UserState.Account>.toCreateEntries(activeUserId: String) =
map { it.toCreateEntry(isActive = activeUserId == it.userId) }
private fun UserState.Account.toCreateEntry(): CreateEntry {
private fun UserState.Account.toCreateEntry(isActive: Boolean): CreateEntry {
val accountName = name ?: email
return CreateEntry
.Builder(
@@ -134,6 +137,9 @@ class Fido2ProviderProcessorImpl(
accountName,
),
)
// Set the last used time to "now" so the active account is the default option in the
// system prompt.
.setLastUsedTime(if (isActive) clock.instant() else null)
.build()
}

View File

@@ -128,7 +128,6 @@ class AutofillProcessorImpl(
autofillRequest = autofillRequest,
)
val saveInfo = saveInfoBuilder.build(
autofillAppInfo = autofillAppInfo,
autofillPartition = autofillRequest.partition,
fillRequest = fillRequest,
packageName = autofillRequest.packageName,

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

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

@@ -0,0 +1,19 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
/**
* Disk data source for saved feature flag overrides.
*/
interface FeatureFlagOverrideDiskSource {
/**
* Save a feature flag [FlagKey] to disk.
*/
fun <T : Any> saveFeatureFlag(key: FlagKey<T>, value: T)
/**
* Get a feature flag value based on the associated [FlagKey] from disk.
*/
fun <T : Any> getFeatureFlag(key: FlagKey<T>): T?
}

View File

@@ -0,0 +1,37 @@
package com.x8bit.bitwarden.data.platform.datasource.disk
import android.content.SharedPreferences
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
/**
* Default implementation of the [FeatureFlagOverrideDiskSource]
*/
class FeatureFlagOverrideDiskSourceImpl(
sharedPreferences: SharedPreferences,
) : FeatureFlagOverrideDiskSource, BaseDiskSource(sharedPreferences) {
override fun <T : Any> saveFeatureFlag(key: FlagKey<T>, value: T) {
when (key.defaultValue) {
is Boolean -> putBoolean(key.keyName, value as Boolean)
is String -> putString(key.keyName, value as String)
is Int -> putInt(key.keyName, value as Int)
else -> Unit
}
}
@Suppress("UNCHECKED_CAST")
override fun <T : Any> getFeatureFlag(key: FlagKey<T>): T? {
return try {
when (key.defaultValue) {
is Boolean -> getBoolean(key.keyName) as? T
is String -> getString(key.keyName) as? T
is Int -> getInt(key.keyName) as? T
else -> null
}
} catch (castException: ClassCastException) {
null
} catch (numberFormatException: NumberFormatException) {
null
}
}
}

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

@@ -12,6 +12,8 @@ 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
import com.x8bit.bitwarden.data.platform.datasource.disk.EventDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSourceImpl
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
@@ -149,4 +151,12 @@ object PlatformDiskModule {
sharedPreferences = sharedPreferences,
json = json,
)
@Provides
@Singleton
fun provideFeatureFlagOverrideDiskSource(
@UnencryptedPreferences sharedPreferences: SharedPreferences,
): FeatureFlagOverrideDiskSource = FeatureFlagOverrideDiskSourceImpl(
sharedPreferences = sharedPreferences,
)
}

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

@@ -23,7 +23,7 @@ const val HEADER_KEY_USER_AGENT: String = "User-Agent"
*/
@Suppress("MaxLineLength")
val HEADER_VALUE_USER_AGENT: String =
"Bitwarden_Mobile/${BuildConfig.VERSION_NAME} (Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT}; Model ${Build.MODEL})"
"Bitwarden_Mobile/${BuildConfig.VERSION_NAME} (${BuildConfig.BUILD_TYPE}/${BuildConfig.FLAVOR}) (Android ${Build.VERSION.RELEASE}; SDK ${Build.VERSION.SDK_INT}; Model ${Build.MODEL})"
/**
* The key used for the 'bitwarden-client-name' headers.

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

@@ -9,9 +9,9 @@ interface BiometricsEncryptionManager {
/**
* Creates a [Cipher] built from a keystore.
*/
fun createCipher(
fun createCipherOrNull(
userId: String,
): Cipher
): Cipher?
/**
* Gets the [Cipher] built from a keystore, or creates one if it doesn't already exist.

View File

@@ -6,13 +6,20 @@ import android.security.keystore.KeyProperties
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import java.io.IOException
import java.security.InvalidAlgorithmParameterException
import java.security.InvalidKeyException
import java.security.KeyStore
import java.security.KeyStoreException
import java.security.NoSuchAlgorithmException
import java.security.NoSuchProviderException
import java.security.ProviderException
import java.security.UnrecoverableKeyException
import java.security.cert.CertificateException
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.NoSuchPaddingException
import javax.crypto.SecretKey
/**
@@ -39,9 +46,20 @@ class BiometricsEncryptionManagerImpl(
.setInvalidatedByBiometricEnrollment(true)
.build()
override fun createCipher(userId: String): Cipher {
val secretKey: SecretKey = generateKey()
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
override fun createCipherOrNull(userId: String): Cipher? {
val secretKey: SecretKey = generateKeyOrNull()
?: run {
// user removed all biometrics from the device
settingsDiskSource.systemBiometricIntegritySource = null
return null
}
val cipher = try {
Cipher.getInstance(CIPHER_TRANSFORMATION)
} catch (e: NoSuchAlgorithmException) {
return null
} catch (e: NoSuchPaddingException) {
return null
}
// This should never fail to initialize / return false because the cipher is newly generated
initializeCipher(
userId = userId,
@@ -52,13 +70,14 @@ class BiometricsEncryptionManagerImpl(
}
override fun getOrCreateCipher(userId: String): Cipher? {
val secretKey = try {
getSecretKey() ?: generateKey()
} catch (e: InvalidAlgorithmParameterException) {
// user removed all biometrics from the device
settingsDiskSource.systemBiometricIntegritySource = null
return null
}
val secretKey = getSecretKeyOrNull()
?: generateKeyOrNull()
?: run {
// user removed all biometrics from the device
settingsDiskSource.systemBiometricIntegritySource = null
return null
}
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
val isCipherInitialized = initializeCipher(
userId = userId,
@@ -88,24 +107,67 @@ class BiometricsEncryptionManagerImpl(
}
/**
* Generates a [SecretKey] from which the [Cipher] will be generated.
* Generates a [SecretKey] from which the [Cipher] will be generated, or `null` if a key cannot
* be generated.
*/
private fun generateKey(): SecretKey {
val keyGen = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ENCRYPTION_KEYSTORE_NAME,
)
keyGen.init(keyGenParameterSpec)
keyGen.generateKey()
return requireNotNull(getSecretKey())
private fun generateKeyOrNull(): SecretKey? {
val keyGen = try {
KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ENCRYPTION_KEYSTORE_NAME,
)
} catch (e: NoSuchAlgorithmException) {
return null
} catch (e: NoSuchProviderException) {
return null
} catch (e: IllegalArgumentException) {
return null
}
try {
keyGen.init(keyGenParameterSpec)
keyGen.generateKey()
} catch (e: InvalidAlgorithmParameterException) {
return null
} catch (e: ProviderException) {
return null
}
return getSecretKeyOrNull()
}
/**
* Returns the [SecretKey] stored in the keystore, or null if there isn't one.
*/
private fun getSecretKey(): SecretKey? {
keystore.load(null)
return keystore.getKey(ENCRYPTION_KEY_NAME, null) as? SecretKey
private fun getSecretKeyOrNull(): SecretKey? {
try {
keystore.load(null)
} catch (e: IllegalArgumentException) {
// keystore could not be loaded because [param] is unrecognized.
return null
} catch (e: IOException) {
// keystore data format is invalid or the password is incorrect.
return null
} catch (e: NoSuchAlgorithmException) {
// keystore integrity could not be checked due to missing algorithm.
return null
} catch (e: CertificateException) {
// keystore certificates could not be loaded
return null
}
return try {
keystore.getKey(ENCRYPTION_KEY_NAME, null) as? SecretKey
} catch (e: KeyStoreException) {
// keystore was not loaded
null
} catch (e: NoSuchAlgorithmException) {
// keystore algorithm cannot be found
null
} catch (e: UnrecoverableKeyException) {
// key could not be recovered
null
}
}
/**
@@ -137,7 +199,7 @@ class BiometricsEncryptionManagerImpl(
* Validates the keystore key and decrypts it using the user-provided [cipher].
*/
private fun isSystemBiometricIntegrityValid(userId: String, cipher: Cipher?): Boolean {
val secretKey = getSecretKey()
val secretKey = getSecretKeyOrNull()
return if (cipher != null && secretKey != null) {
initializeCipher(
userId = userId,
@@ -165,12 +227,9 @@ class BiometricsEncryptionManagerImpl(
value = true,
)
try {
createCipher(userId)
} catch (e: Exception) {
// Catch silently to allow biometrics to function on devices that are in
// a state where key generation is not functioning
}
// Ignore result so biometrics function on devices that are in a state where key generation
// is not functioning
createCipherOrNull(userId)
}
}

View File

@@ -0,0 +1,37 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* The [FeatureFlagManager] implementation for the debug menu. This manager uses the
* values returned from the [debugMenuRepository] if they are available. otherwise it will use
* the default [FeatureFlagManager].
*/
class DebugMenuFeatureFlagManagerImpl(
private val defaultFeatureFlagManager: FeatureFlagManager,
private val debugMenuRepository: DebugMenuRepository,
) : FeatureFlagManager by defaultFeatureFlagManager {
override fun <T : Any> getFeatureFlagFlow(key: FlagKey<T>): Flow<T> {
return debugMenuRepository.featureFlagOverridesUpdatedFlow.map { _ ->
debugMenuRepository
.getFeatureFlag(key)
?: defaultFeatureFlagManager.getFeatureFlag(key = key)
}
}
override suspend fun <T : Any> getFeatureFlag(key: FlagKey<T>, forceRefresh: Boolean): T {
return debugMenuRepository
.getFeatureFlag(key)
?: defaultFeatureFlagManager.getFeatureFlag(key = key, forceRefresh = forceRefresh)
}
override fun <T : Any> getFeatureFlag(key: FlagKey<T>): T {
return debugMenuRepository
.getFeatureFlag(key)
?: defaultFeatureFlagManager.getFeatureFlag(key = key)
}
}

View File

@@ -40,9 +40,15 @@ class FeatureFlagManagerImpl(
.getFlagValueOrDefault(key = key)
}
private fun <T : Any> ServerConfig?.getFlagValueOrDefault(key: FlagKey<T>): T {
/**
* Extract the value of a [FlagKey] from the [ServerConfig]. If there is an issue with retrieving
* or if the value is null, the default value will be returned.
*/
fun <T : Any> ServerConfig?.getFlagValueOrDefault(key: FlagKey<T>): T {
val defaultValue = key.defaultValue
return this?.serverData
if (!key.isRemotelyConfigured) return key.defaultValue
return this
?.serverData
?.featureStates
?.get(key.keyName)
?.let {

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

@@ -1,14 +1,37 @@
package com.x8bit.bitwarden.data.platform.manager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
/**
* Primary implementation of [SpecialCircumstanceManager].
*/
class SpecialCircumstanceManagerImpl : SpecialCircumstanceManager {
class SpecialCircumstanceManagerImpl(
authRepository: AuthRepository,
dispatcherManager: DispatcherManager,
) : SpecialCircumstanceManager {
private val mutableSpecialCircumstanceFlow = MutableStateFlow<SpecialCircumstance?>(null)
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
init {
authRepository
.userStateFlow
.filter {
it?.activeAccount?.isLoggedIn == true
}
.onEach { _ ->
if (specialCircumstance is SpecialCircumstance.PreLogin) {
specialCircumstance = null
}
}
.launchIn(unconfinedScope)
}
override var specialCircumstance: SpecialCircumstance?
get() = mutableSpecialCircumstanceFlow.value

View File

@@ -1,8 +1,10 @@
package com.x8bit.bitwarden.data.platform.manager.di
import com.x8bit.bitwarden.MainActivity
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl
import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -20,6 +22,12 @@ class ActivityPlatformManagerModule {
@Provides
@ActivityRetainedScoped
fun provideActivityScopedSpecialCircumstanceRepository(): SpecialCircumstanceManager =
SpecialCircumstanceManagerImpl()
fun provideActivityScopedSpecialCircumstanceRepository(
authRepository: AuthRepository,
dispatcher: DispatcherManager,
): SpecialCircumstanceManager =
SpecialCircumstanceManagerImpl(
authRepository = authRepository,
dispatcherManager = dispatcher,
)
}

View File

@@ -22,6 +22,7 @@ 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.DebugMenuFeatureFlagManagerImpl
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManagerImpl
import com.x8bit.bitwarden.data.platform.manager.NetworkConfigManager
@@ -48,6 +49,7 @@ import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManage
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManagerImpl
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.DebugMenuRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
@@ -141,11 +143,20 @@ object PlatformManagerModule {
@Provides
@Singleton
fun providesFeatureFlagManager(
debugMenuRepository: DebugMenuRepository,
serverConfigRepository: ServerConfigRepository,
): FeatureFlagManager =
): FeatureFlagManager = if (debugMenuRepository.isDebugMenuEnabled) {
DebugMenuFeatureFlagManagerImpl(
debugMenuRepository = debugMenuRepository,
defaultFeatureFlagManager = FeatureFlagManagerImpl(
serverConfigRepository = serverConfigRepository,
),
)
} else {
FeatureFlagManagerImpl(
serverConfigRepository = serverConfigRepository,
)
}
@Provides
@Singleton
@@ -161,6 +172,7 @@ object PlatformManagerModule {
authRepository: AuthRepository,
authTokenInterceptor: AuthTokenInterceptor,
environmentRepository: EnvironmentRepository,
serverConfigRepository: ServerConfigRepository,
baseUrlInterceptors: BaseUrlInterceptors,
refreshAuthenticator: RefreshAuthenticator,
dispatcherManager: DispatcherManager,
@@ -169,6 +181,7 @@ object PlatformManagerModule {
authRepository = authRepository,
authTokenInterceptor = authTokenInterceptor,
environmentRepository = environmentRepository,
serverConfigRepository = serverConfigRepository,
baseUrlInterceptors = baseUrlInterceptors,
refreshAuthenticator = refreshAuthenticator,
dispatcherManager = dispatcherManager,

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

@@ -2,50 +2,88 @@ package com.x8bit.bitwarden.data.platform.manager.model
/**
* Class to hold feature flag keys.
* @property [keyName] corresponds to the string value of a given key
* @property [defaultValue] corresponds to default value of the flag of type [T]
*/
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
/**
* Data object holding the key for Email Verification feature
* Indicates if the flag should respect the network value or not.
*/
abstract val isRemotelyConfigured: Boolean
companion object {
/**
* List of all flag keys to consider
*/
val activeFlags: List<FlagKey<*>> by lazy {
listOf(
EmailVerification,
OnboardingFlow,
OnboardingCarousel,
)
}
}
/**
* 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 = true
}
/**
* Data object holding the feature flag key for the Onboarding Carousel feature
* 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 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 an Int flag to be used in tests
* Data object holding the key for a [Boolean] flag to be used in tests.
*/
data object DummyInt : FlagKey<Int>() {
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 an String flag to be used in tests
* 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

@@ -8,6 +8,7 @@ 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
import kotlinx.parcelize.Parcelize
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
/**
* Represents a special circumstance the app may be in. These circumstances could require some kind
@@ -88,4 +89,23 @@ sealed class SpecialCircumstance : Parcelable {
*/
@Parcelize
data object VaultShortcut : SpecialCircumstance()
/**
* A subset of [SpecialCircumstance] that are only relevant in a pre-login state and should be
* cleared after a successful login.
*
* @see [SpecialCircumstanceManager.clearSpecialCircumstanceAfterLogin]
*/
@Parcelize
sealed class PreLogin : 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,
) : PreLogin()
}
}

View File

@@ -21,6 +21,7 @@ fun SpecialCircumstance.toAutofillSaveItemOrNull(): AutofillSaveItem? =
is SpecialCircumstance.Fido2Save -> null
is SpecialCircumstance.Fido2Assertion -> null
is SpecialCircumstance.Fido2GetCredentials -> null
is SpecialCircumstance.PreLogin.CompleteRegistration -> null
}
/**
@@ -37,6 +38,7 @@ fun SpecialCircumstance.toAutofillSelectionDataOrNull(): AutofillSelectionData?
is SpecialCircumstance.Fido2Save -> null
is SpecialCircumstance.Fido2Assertion -> null
is SpecialCircumstance.Fido2GetCredentials -> null
is SpecialCircumstance.PreLogin.CompleteRegistration -> null
}
/**

View File

@@ -0,0 +1,35 @@
package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import kotlinx.coroutines.flow.Flow
/**
* Repository for accessing data required or associated with the debug menu.
*/
interface DebugMenuRepository {
/**
* Value to determine if the debug menu is enabled.
*/
val isDebugMenuEnabled: Boolean
/**
* Observable flow for when any of the feature flag overrides have been updated.
*/
val featureFlagOverridesUpdatedFlow: Flow<Unit>
/**
* Update a feature flag which matches the given [key] to the given [value].
*/
fun <T : Any> updateFeatureFlag(key: FlagKey<T>, value: T)
/**
* Get a feature flag value based on the associated [FlagKey].
*/
fun <T : Any> getFeatureFlag(key: FlagKey<T>): T?
/**
* Reset all feature flag overrides to their default values or values from the network.
*/
fun resetFeatureFlagOverrides()
}

View File

@@ -0,0 +1,45 @@
package com.x8bit.bitwarden.data.platform.repository
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.platform.datasource.disk.FeatureFlagOverrideDiskSource
import com.x8bit.bitwarden.data.platform.manager.getFlagValueOrDefault
import com.x8bit.bitwarden.data.platform.manager.model.FlagKey
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onSubscription
/**
* Default implementation of the [DebugMenuRepository]
*/
class DebugMenuRepositoryImpl(
private val featureFlagOverrideDiskSource: FeatureFlagOverrideDiskSource,
private val serverConfigRepository: ServerConfigRepository,
) : DebugMenuRepository {
private val mutableOverridesUpdatedFlow = bufferedMutableSharedFlow<Unit>(replay = 1)
override val featureFlagOverridesUpdatedFlow: Flow<Unit> = mutableOverridesUpdatedFlow
.onSubscription { emit(Unit) }
override val isDebugMenuEnabled: Boolean
get() = BuildConfig.HAS_DEBUG_MENU
override fun <T : Any> updateFeatureFlag(key: FlagKey<T>, value: T) {
featureFlagOverrideDiskSource.saveFeatureFlag(key = key, value = value)
mutableOverridesUpdatedFlow.tryEmit(Unit)
}
override fun <T : Any> getFeatureFlag(key: FlagKey<T>): T? =
featureFlagOverrideDiskSource.getFeatureFlag(
key = key,
)
override fun resetFeatureFlagOverrides() {
val currentServerConfig = serverConfigRepository.serverConfigStateFlow.value
FlagKey.activeFlags.forEach { flagKey ->
updateFeatureFlag(
flagKey,
currentServerConfig.getFlagValueOrDefault(flagKey),
)
}
}
}

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

@@ -8,14 +8,14 @@ import kotlinx.coroutines.flow.StateFlow
*/
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?
/**
* Emits updates that track [ServerConfig].
*/
val serverConfigStateFlow: StateFlow<ServerConfig?>
}

View File

@@ -7,8 +7,6 @@ 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.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import java.time.Clock
import java.time.Instant
@@ -20,20 +18,19 @@ class ServerConfigRepositoryImpl(
private val configDiskSource: ConfigDiskSource,
private val configService: ConfigService,
private val clock: Clock,
environmentRepository: EnvironmentRepository,
dispatcherManager: DispatcherManager,
) : ServerConfigRepository {
private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined)
init {
environmentRepository
.environmentStateFlow
.onEach {
getServerConfig(true)
}
.launchIn(unconfinedScope)
}
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
@@ -62,15 +59,6 @@ class ServerConfigRepositoryImpl(
return localConfig
}
override val serverConfigStateFlow: StateFlow<ServerConfig?>
get() = configDiskSource
.serverConfigFlow
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = configDiskSource.serverConfig,
)
companion object {
private const val MINIMUM_CONFIG_SYNC_INTERVAL_SEC: Long = 60 * 60
}

View File

@@ -325,14 +325,23 @@ class SettingsRepositoryImpl(
override fun setDefaultsIfNecessary(userId: String) {
// Set Vault Settings defaults
if (!isVaultTimeoutActionSet(userId = userId)) {
val hasMasterPassword = authDiskSource
.userState
?.activeAccount
?.profile
?.userDecryptionOptions
?.hasMasterPassword != false
val timeoutAction = settingsDiskSource.getVaultTimeoutAction(userId = userId)
val hasPin = authDiskSource.getPinProtectedUserKey(userId = userId) != null
val hasBiometrics = authDiskSource.getUserBiometricUnlockKey(userId = userId) != null
// The timeout action cannot be "lock" if you do not have master password, pin, or
// biometrics unlock enabled.
val hasInvalidTimeoutAction = timeoutAction == VaultTimeoutAction.LOCK &&
!hasPin &&
!hasBiometrics &&
!hasMasterPassword
if (!isVaultTimeoutActionSet(userId = userId) || hasInvalidTimeoutAction) {
storeVaultTimeout(userId, VaultTimeout.FifteenMinutes)
val hasMasterPassword = authDiskSource
.userState
?.activeAccount
?.profile
?.userDecryptionOptions
?.hasMasterPassword != false
storeVaultTimeoutAction(
userId = userId,
vaultTimeoutAction = if (!hasMasterPassword) {

View File

@@ -5,11 +5,14 @@ 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.FeatureFlagOverrideDiskSource
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.DebugMenuRepository
import com.x8bit.bitwarden.data.platform.repository.DebugMenuRepositoryImpl
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepositoryImpl
import com.x8bit.bitwarden.data.platform.repository.ServerConfigRepository
@@ -37,14 +40,12 @@ object PlatformRepositoryModule {
configDiskSource: ConfigDiskSource,
configService: ConfigService,
clock: Clock,
environmentRepository: EnvironmentRepository,
dispatcherManager: DispatcherManager,
): ServerConfigRepository =
ServerConfigRepositoryImpl(
configDiskSource = configDiskSource,
configService = configService,
clock = clock,
environmentRepository = environmentRepository,
dispatcherManager = dispatcherManager,
)
@@ -83,4 +84,14 @@ object PlatformRepositoryModule {
dispatcherManager = dispatcherManager,
policyManager = policyManager,
)
@Provides
@Singleton
fun provideDebugMenuRepository(
featureFlagOverrideDiskSource: FeatureFlagOverrideDiskSource,
serverConfigRepository: ServerConfigRepository,
): DebugMenuRepository = DebugMenuRepositoryImpl(
featureFlagOverrideDiskSource = featureFlagOverrideDiskSource,
serverConfigRepository = serverConfigRepository,
)
}

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,38 +0,0 @@
package com.x8bit.bitwarden.data.platform.util
import java.util.Locale
/**
* Compare two characters, where a special character is considered with higher precedence over
* letters and numbers. If both characters are a letter or a digit use the default
* [Char.compareTo].
*/
private fun compareCharsSpecialCharsWithPrecedence(c1: Char, c2: Char): Int {
return when {
c1.isLetterOrDigit() && !c2.isLetterOrDigit() -> 1
!c1.isLetterOrDigit() && c2.isLetterOrDigit() -> -1
else -> c1.compareTo(c2)
}
}
/**
* String [Comparator] where the characters are compared giving precedence to
* special characters.
*/
object CompareStringSpecialCharWithPrecedence : Comparator<String> {
override fun compare(str1: String, str2: String): Int {
val uppercaseStr1 = str1.uppercase(Locale.getDefault())
val uppercaseStr2 = str2.uppercase(Locale.getDefault())
val minLength = minOf(uppercaseStr1.length, uppercaseStr2.length)
for (i in 0 until minLength) {
val char1 = uppercaseStr1[i]
val char2 = uppercaseStr2[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 uppercaseStr1.length - uppercaseStr2.length
}
}

View File

@@ -0,0 +1,43 @@
package com.x8bit.bitwarden.data.tiles
import android.annotation.SuppressLint
import android.os.Build
import android.service.quicksettings.TileService
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Runnable
import javax.inject.Inject
/**
* A service for handling the Password Generator quick settings tile.
*/
@AndroidEntryPoint
@Keep
@OmitFromCoverage
class BitwardenGeneratorTileService : TileService() {
@Inject
lateinit var intentManager: IntentManager
override fun onClick() {
if (isLocked) {
unlockAndRun(Runnable { launchGenerator() })
} else {
launchGenerator()
}
}
@Suppress("DEPRECATION")
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun launchGenerator() {
val intent = intentManager.createTileIntent("bitwarden://password_generator")
if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) {
startActivityAndCollapse(intent)
} else {
startActivityAndCollapse(intentManager.createTilePendingIntent(0, intent))
}
}
}

View File

@@ -0,0 +1,43 @@
package com.x8bit.bitwarden.data.tiles
import android.annotation.SuppressLint
import android.os.Build
import android.service.quicksettings.TileService
import androidx.annotation.Keep
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.data.platform.util.isBuildVersionBelow
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Runnable
import javax.inject.Inject
/**
* A service for handling the My Vault quick settings tile.
*/
@AndroidEntryPoint
@Keep
@OmitFromCoverage
class BitwardenVaultTileService : TileService() {
@Inject
lateinit var intentManager: IntentManager
override fun onClick() {
if (isLocked) {
unlockAndRun(Runnable { launchVault() })
} else {
launchVault()
}
}
@Suppress("DEPRECATION")
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun launchVault() {
val intent = intentManager.createTileIntent("bitwarden://my_vault")
if (isBuildVersionBelow(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)) {
startActivityAndCollapse(intent)
} else {
startActivityAndCollapse(intentManager.createTilePendingIntent(0, intent))
}
}
}

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

@@ -113,6 +113,9 @@ data class UsernameGenerationOptions(
@SerialName("4")
FASTMAIL,
@SerialName("5")
FORWARD_EMAIL,
}
}

View File

@@ -35,10 +35,7 @@ object VaultNetworkModule {
clock: Clock,
): CiphersService = CiphersServiceImpl(
azureApi = retrofits
.staticRetrofitBuilder
// This URL will be overridden dynamically
.baseUrl("https://www.bitwarden.com")
.build()
.createStaticRetrofit()
.create(),
ciphersApi = retrofits.authenticatedApiRetrofit.create(),
json = json,
@@ -63,10 +60,7 @@ object VaultNetworkModule {
clock: Clock,
): SendsService = SendsServiceImpl(
azureApi = retrofits
.staticRetrofitBuilder
// This URL will be overridden dynamically
.baseUrl("https://www.bitwarden.com")
.build()
.createStaticRetrofit()
.create(),
sendsApi = retrofits.authenticatedApiRetrofit.create(),
json = json,
@@ -87,10 +81,7 @@ object VaultNetworkModule {
retrofits: Retrofits,
): DownloadService = DownloadServiceImpl(
downloadApi = retrofits
.staticRetrofitBuilder
// This URL will be overridden dynamically
.baseUrl("https://www.bitwarden.com")
.build()
.createStaticRetrofit()
.create(),
)
}

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