diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 83e6c2d696e..3990a8bef95 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -193,7 +193,7 @@ jobs: zip -r browser-source.zip browser-source - name: Upload browser source - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: ${{matrix.license_type.archive_name_prefix}}browser-source-${{ env._BUILD_NUMBER }}.zip path: browser-source.zip @@ -218,6 +218,7 @@ jobs: source_archive_name_prefix: "" archive_name_prefix: "" npm_command_prefix: "dist:" + npm_package_dev_prefix: "package:dev:" readable: "open source license" type: "oss" - build_prefix: "bit-" @@ -225,6 +226,7 @@ jobs: source_archive_name_prefix: "bit-" archive_name_prefix: "bit-" npm_command_prefix: "dist:bit:" + npm_package_dev_prefix: "package:bit:dev:" readable: "commercial license" type: "commercial" browser: @@ -232,6 +234,8 @@ jobs: npm_command_suffix: "chrome" archive_name: "dist-chrome.zip" artifact_name: "dist-chrome-MV3" + artifact_name_dev: "dev-chrome-MV3" + archive_name_dev: "dev-chrome.zip" - name: "edge" npm_command_suffix: "edge" archive_name: "dist-edge.zip" @@ -268,7 +272,7 @@ jobs: npm --version - name: Download browser source - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: ${{matrix.license_type.source_archive_name_prefix}}browser-source-${{ env._BUILD_NUMBER }}.zip @@ -332,12 +336,25 @@ jobs: working-directory: browser-source/apps/browser - name: Upload extension artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: ${{ matrix.license_type.artifact_prefix }}${{ matrix.browser.artifact_name }}-${{ env._BUILD_NUMBER }}.zip path: browser-source/apps/browser/dist/${{matrix.license_type.archive_name_prefix}}${{ matrix.browser.archive_name }} if-no-files-found: error + - name: Package dev extension + if: ${{ matrix.browser.archive_name_dev != '' }} + run: npm run ${{ matrix.license_type.npm_package_dev_prefix }}${{ matrix.browser.npm_command_suffix }} + working-directory: browser-source/apps/browser + + - name: Upload dev extension artifact + if: ${{ matrix.browser.archive_name_dev != '' }} + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: ${{ matrix.license_type.artifact_prefix }}${{ matrix.browser.artifact_name_dev }}-${{ env._BUILD_NUMBER }}.zip + path: browser-source/apps/browser/dist/${{matrix.license_type.archive_name_prefix}}${{ matrix.browser.archive_name_dev }} + if-no-files-found: error + build-safari: name: Build Safari - ${{ matrix.license_type.readable }} @@ -506,7 +523,7 @@ jobs: ls -la - name: Upload Safari artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: ${{matrix.license_type.archive_name_prefix}}dist-safari-${{ env._BUILD_NUMBER }}.zip path: apps/browser/dist/${{matrix.license_type.archive_name_prefix}}dist-safari.zip diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 414c043b89e..babd00a323f 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -268,7 +268,7 @@ jobs: fi - name: Upload unix zip asset - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-${{ env.LOWER_RUNNER_OS }}${{ matrix.os.target_suffix }}-${{ env._PACKAGE_VERSION }}.zip @@ -482,7 +482,7 @@ jobs: } - name: Upload windows zip asset - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bw${{ matrix.license_type.artifact_prefix }}-windows-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/bw${{ matrix.license_type.artifact_prefix }}-windows-${{ env._PACKAGE_VERSION }}.zip @@ -490,7 +490,7 @@ jobs: - name: Upload Chocolatey asset if: matrix.license_type.build_prefix == 'bit' - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden-cli.${{ env._PACKAGE_VERSION }}.nupkg path: apps/cli/dist/chocolatey/bitwarden-cli.${{ env._PACKAGE_VERSION }}.nupkg @@ -503,7 +503,7 @@ jobs: - name: Upload NPM Build Directory asset if: matrix.license_type.build_prefix == 'bit' - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden-cli-${{ env._PACKAGE_VERSION }}-npm-build.zip path: apps/cli/bitwarden-cli-${{ env._PACKAGE_VERSION }}-npm-build.zip @@ -535,7 +535,7 @@ jobs: echo "BW Package Version: $_PACKAGE_VERSION" - name: Get bw linux cli - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: bw-linux-${{ env._PACKAGE_VERSION }}.zip path: apps/cli/dist/snap @@ -572,7 +572,7 @@ jobs: run: sudo snap remove bw - name: Upload snap asset - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bw_${{ env._PACKAGE_VERSION }}_amd64.snap path: apps/cli/dist/snap/bw_${{ env._PACKAGE_VERSION }}_amd64.snap diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index cd5a392bb6b..aeab2d791f3 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -257,35 +257,35 @@ jobs: run: npm run dist:lin - name: Upload .deb artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-amd64.deb if-no-files-found: error - name: Upload .rpm artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.rpm if-no-files-found: error - name: Upload .snap artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap path: apps/desktop/dist/bitwarden_${{ env._PACKAGE_VERSION }}_amd64.snap if-no-files-found: error - name: Upload .AppImage artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x86_64.AppImage if-no-files-found: error - name: Upload auto-update artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: ${{ needs.setup.outputs.release_channel }}-linux.yml path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-linux.yml @@ -298,7 +298,7 @@ jobs: sudo npm run pack:lin:flatpak - name: Upload flatpak artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: com.bitwarden.desktop.flatpak path: apps/desktop/dist/com.bitwarden.desktop.flatpak @@ -426,14 +426,14 @@ jobs: run: npm run dist:lin:arm64 - name: Upload .snap artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_arm64.snap path: apps/desktop/dist/bitwarden_${{ env._PACKAGE_VERSION }}_arm64.snap if-no-files-found: error - name: Upload tar.gz artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden_${{ env._PACKAGE_VERSION }}_arm64.tar.gz path: apps/desktop/dist/bitwarden_desktop_arm64.tar.gz @@ -446,7 +446,7 @@ jobs: sudo npm run pack:lin:flatpak - name: Upload flatpak artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: com.bitwarden.desktop-arm64.flatpak path: apps/desktop/dist/com.bitwarden.desktop.flatpak @@ -617,7 +617,7 @@ jobs: -NewName bitwarden-$env:_PACKAGE_VERSION-arm64.nsis.7z - name: Upload portable exe artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe @@ -625,15 +625,15 @@ jobs: - name: Upload installer exe artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: - name: Bitwarden-Installer-${{ env._PACKAGE_VERSION }}..exe + name: Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/nsis-web/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe if-no-files-found: error - name: Upload appx ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx @@ -641,7 +641,7 @@ jobs: - name: Upload store appx ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx @@ -649,7 +649,7 @@ jobs: - name: Upload NSIS ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z @@ -657,7 +657,7 @@ jobs: - name: Upload appx x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx @@ -665,7 +665,7 @@ jobs: - name: Upload store appx x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx @@ -673,7 +673,7 @@ jobs: - name: Upload NSIS x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z @@ -681,7 +681,7 @@ jobs: - name: Upload appx ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx @@ -689,7 +689,7 @@ jobs: - name: Upload store appx ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx @@ -697,7 +697,7 @@ jobs: - name: Upload NSIS ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z @@ -705,7 +705,7 @@ jobs: - name: Upload nupkg artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden.${{ env._PACKAGE_VERSION }}.nupkg path: apps/desktop/dist/chocolatey/bitwarden.${{ env._PACKAGE_VERSION }}.nupkg @@ -713,7 +713,7 @@ jobs: - name: Upload auto-update artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: ${{ needs.setup.outputs.release_channel }}.yml path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml @@ -868,7 +868,7 @@ jobs: -NewName latest-beta.yml - name: Upload portable exe artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe @@ -876,7 +876,7 @@ jobs: - name: Upload installer exe artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe path: apps/desktop/dist/nsis-web/Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe @@ -884,7 +884,7 @@ jobs: - name: Upload appx ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx @@ -892,7 +892,7 @@ jobs: - name: Upload store appx ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx @@ -900,7 +900,7 @@ jobs: - name: Upload NSIS ia32 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z @@ -908,7 +908,7 @@ jobs: - name: Upload appx x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx @@ -916,7 +916,7 @@ jobs: - name: Upload store appx x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx @@ -924,7 +924,7 @@ jobs: - name: Upload NSIS x64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z @@ -932,7 +932,7 @@ jobs: - name: Upload appx ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx @@ -940,7 +940,7 @@ jobs: - name: Upload store appx ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx @@ -948,7 +948,7 @@ jobs: - name: Upload NSIS ARM64 artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z @@ -956,7 +956,7 @@ jobs: - name: Upload auto-update artifact if: ${{ needs.setup.outputs.has_secrets == 'true' }} - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: latest-beta.yml path: apps/desktop/dist/nsis-web/latest-beta.yml @@ -1408,7 +1408,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: path: ${{ github.workspace }}/browser-build-artifacts @@ -1441,28 +1441,28 @@ jobs: run: npm run pack:mac - name: Upload .zip artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal-mac.zip if-no-files-found: error - name: Upload .dmg artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg if-no-files-found: error - name: Upload .dmg blockmap artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.dmg.blockmap if-no-files-found: error - name: Upload auto-update artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: ${{ needs.setup.outputs.release_channel }}-mac.yml path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-mac.yml @@ -1691,7 +1691,7 @@ jobs: run: npm run build - name: Download Browser artifact - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: path: ${{ github.workspace }}/browser-build-artifacts @@ -1734,14 +1734,14 @@ jobs: $buildInfo | ConvertTo-Json | Set-Content -Path dist/macos-build-number.json - name: Upload MacOS App Store build number artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: macos-build-number.json path: apps/desktop/dist/macos-build-number.json if-no-files-found: error - name: Upload .pkg artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg path: apps/desktop/dist/mas-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 719063958f7..caf806af9f0 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -307,7 +307,7 @@ jobs: zip -r web-$_VERSION-${{ matrix.artifact_name }}.zip build - name: Upload ${{ matrix.artifact_name }} artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: web-${{ env._VERSION }}-${{ matrix.artifact_name }}.zip path: apps/web/web-${{ env._VERSION }}-${{ matrix.artifact_name }}.zip @@ -334,7 +334,7 @@ jobs: - name: Scan Docker image if: ${{ needs.setup.outputs.has_secrets == 'true' }} id: container-scan - uses: anchore/scan-action@1638637db639e0ade3258b51db49a9a137574c3e # v6.5.1 + uses: anchore/scan-action@568b89d27fc18c60e56937bff480c91c772cd993 # v7.1.0 with: image: ${{ steps.image-name.outputs.name }} fail-build: false diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml index 19532493071..311737a2c0e 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -54,6 +54,8 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + permission-contents: write # for creating, committing to, and pushing new branches + permission-pull-requests: write # for generating pull requests - name: Checkout repo uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c14abd7cd86..67186905390 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -98,12 +98,27 @@ jobs: with: persist-credentials: false + - name: Install Rust + uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # stable + with: + toolchain: stable + components: rustfmt, clippy + + - name: Install Rust nightly + uses: dtolnay/rust-toolchain@6d653acede28d24f02e3cd41383119e8b1b35921 # stable + with: + toolchain: nightly + components: rustfmt + - name: Check Rust version run: rustup --version + - name: Cache cargo registry + uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7 + - name: Run cargo fmt working-directory: ./apps/desktop/desktop_native - run: cargo fmt --check + run: cargo +nightly fmt --check - name: Run Clippy working-directory: ./apps/desktop/desktop_native @@ -118,6 +133,13 @@ jobs: working-directory: ./apps/desktop/desktop_native run: cargo sort --workspace --check + - name: Install cargo-udeps + run: cargo install cargo-udeps --version 0.1.57 --locked + + - name: Cargo udeps + working-directory: ./apps/desktop/desktop_native + run: cargo +nightly udeps --workspace --all-features --all-targets + - name: Install cargo-deny uses: taiki-e/install-action@81ee1d48d9194cdcab880cbdc7d36e87d39874cb # v2.62.45 with: diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 08d3f1de503..426947526a4 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -206,7 +206,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: persist-credentials: false - + - name: Get Node version id: retrieve-node-version working-directory: ./ diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml index f42f9811d77..b17312950e9 100644 --- a/.github/workflows/publish-desktop.yml +++ b/.github/workflows/publish-desktop.yml @@ -336,7 +336,7 @@ jobs: ruby-version: '3.4.7' bundler-cache: false working-directory: apps/desktop - + - name: Install Fastlane working-directory: apps/desktop run: gem install fastlane @@ -377,15 +377,15 @@ jobs: echo "📦 Publishing build $BUILD_NUMBER to Mac App Store" IS_DRY_RUN="false" fi - + echo "📝 Release notes (${#CHANGELOG} chars): ${CHANGELOG:0:100}..." - + # Validate changelog length (App Store limit is 4000 chars) if [ ${#CHANGELOG} -gt 4000 ]; then echo "❌ Release notes too long: ${#CHANGELOG} characters (max 4000)" exit 1 fi - + fastlane publish --verbose \ app_version:"${_PKG_VERSION}" \ build_number:"$BUILD_NUMBER" \ diff --git a/.github/workflows/publish-web.yml b/.github/workflows/publish-web.yml index 6bf2b282b38..4f41898a9b2 100644 --- a/.github/workflows/publish-web.yml +++ b/.github/workflows/publish-web.yml @@ -157,11 +157,10 @@ jobs: - name: Log out of Docker run: docker logout - self-host-unified-build: - name: Trigger self-host unified build + bitwarden-lite-build: + name: Trigger Bitwarden Lite build runs-on: ubuntu-22.04 - needs: - - setup + needs: setup permissions: id-token: write steps: @@ -182,7 +181,7 @@ jobs: - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main - - name: Trigger self-host build + - name: Trigger Bitwarden Lite build uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }} @@ -190,7 +189,7 @@ jobs: await github.rest.actions.createWorkflowDispatch({ owner: 'bitwarden', repo: 'self-host', - workflow_id: 'build-unified.yml', + workflow_id: 'build-bitwarden-lite.yml', ref: 'main', inputs: { use_latest_core_version: true diff --git a/.github/workflows/sdk-breaking-change-check.yml b/.github/workflows/sdk-breaking-change-check.yml index 759f2292d2a..1b9653417f2 100644 --- a/.github/workflows/sdk-breaking-change-check.yml +++ b/.github/workflows/sdk-breaking-change-check.yml @@ -58,6 +58,8 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + permission-actions: read # for reading and downloading the artifacts for a workflow run + - name: Log out from Azure uses: bitwarden/gh-actions/azure-logout@main diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 71f8e7c9155..f471826355f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -71,10 +71,10 @@ jobs: fail-on-error: true - name: Upload results to codecov.io - uses: codecov/test-results-action@f2dba722c67b86c6caa034178c6e4d35335f6706 # v1.1.0 + uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1.1.1 - name: Upload test coverage - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: jest-coverage path: ./coverage/lcov.info @@ -160,7 +160,7 @@ jobs: run: cargo llvm-cov --all-features --lcov --output-path lcov.info --workspace --no-cfg-coverage - name: Upload test coverage - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: rust-coverage path: ./apps/desktop/desktop_native/lcov.info @@ -178,13 +178,13 @@ jobs: persist-credentials: false - name: Download jest coverage - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: jest-coverage path: ./ - name: Download rust coverage - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: rust-coverage path: ./apps/desktop/desktop_native diff --git a/.github/workflows/version-auto-bump.yml b/.github/workflows/version-auto-bump.yml index 9ff252d2fe8..d807dd046d3 100644 --- a/.github/workflows/version-auto-bump.yml +++ b/.github/workflows/version-auto-bump.yml @@ -36,6 +36,7 @@ jobs: with: app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} + permission-contents: write # for committing and pushing to the current branch - name: Check out target ref uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 diff --git a/apps/browser/package.json b/apps/browser/package.json index 82d2ad7ab7a..a6a88b53db0 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,11 +1,13 @@ { "name": "@bitwarden/browser", - "version": "2025.11.0", + "version": "2025.11.1", "scripts": { "build": "npm run build:chrome", "build:bit": "npm run build:bit:chrome", "build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", "build:bit:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js", + "build:dev:chrome": "npm run build:chrome && npm run update:dev:chrome", + "build:bit:dev:chrome": "npm run build:bit:chrome && npm run update:dev:chrome", "build:edge": "cross-env BROWSER=edge MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", "build:bit:edge": "cross-env BROWSER=edge MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js", "build:firefox": "cross-env BROWSER=firefox NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", @@ -55,9 +57,12 @@ "dist:bit:opera:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:bit:opera", "dist:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:safari", "dist:bit:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:bit:safari", + "package:dev:chrome": "npm run update:dev:chrome && ./scripts/compress.sh dev-chrome.zip", + "package:bit:dev:chrome": "npm run update:dev:chrome && ./scripts/compress.sh bit-dev-chrome.zip", "test": "jest", "test:watch": "jest --watch", "test:watch:all": "jest --watchAll", - "test:clearCache": "jest --clear-cache" + "test:clearCache": "jest --clear-cache", + "update:dev:chrome": "./scripts/update-manifest-dev.sh" } } diff --git a/apps/browser/scripts/update-manifest-dev.sh b/apps/browser/scripts/update-manifest-dev.sh new file mode 100755 index 00000000000..2823d4cb510 --- /dev/null +++ b/apps/browser/scripts/update-manifest-dev.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +#### +# Update the manifest key in the build directory. +#### + +set -e +set -u +set -x +set -o pipefail + +SCRIPT_ROOT="$(dirname "$0")" +BUILD_DIR="$SCRIPT_ROOT/../build" + +# Check if build directory exists +if [ -d "$BUILD_DIR" ]; then + cd "$BUILD_DIR" + + # Update manifest with dev public key + MANIFEST_PATH="./manifest.json" + + # Generated arbitrary public key from Chrome Dev Console to pin side-loaded extension IDs during development + DEV_PUBLIC_KEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuIvjtsAVWZM0i5jFhSZcrmwgaf3KWcxM5F16LNDNeivC1EqJ+H5xNZ5R9UN5ueHA2xyyYAOlxY07OcY6CKTGJRJyefbUhszb66sdx26SV5gVkCois99fKBlsbSbd6und/BJYmoFUWvFCNNVH+OxLMqMQWjMMhM2ItLqTYi7dxRE5qd+7LwQpnGG2vTkm/O7nu8U3CtkfcIAGLsiTd7/iuytcMDnC0qFM5tJyY/5I+9QOhpUJ7Ybj3C18BDWDORhqxutWv+MSw//SgUn2/lPQrnrKq7FIVQL7FxxEPqkv4QwFvaixps1cBbMdJ1Ygit1z5JldoSyNxzCa5vVcJLecMQIDAQAB' + + MANIFEST_PATH_TMP="${MANIFEST_PATH}.tmp" + if jq --arg key "$DEV_PUBLIC_KEY" '.key = $key' "$MANIFEST_PATH" > "$MANIFEST_PATH_TMP"; then + mv "$MANIFEST_PATH_TMP" "$MANIFEST_PATH" + echo "Updated manifest key in $MANIFEST_PATH" + else + echo "ERROR: Failed to update manifest with jq" + rm -f "$MANIFEST_PATH_TMP" + exit 1 + fi +fi diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index 79d54193b59..505a8404233 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "تعيين كلمة مرور رئيسية" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index b67d5ace0d4..086b66380b5 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -595,7 +595,7 @@ "message": "Hamısına bax" }, "viewLess": { - "message": "View less" + "message": "Daha azına bax" }, "viewLogin": { "message": "Girişə bax" @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Ana parolu ayarla" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "\"Premium\"a yüksəlt" }, + "upgradeCompleteSecurity": { + "message": "Tam təhlükəsizlik üçün yüksəldin" + }, + "premiumGivesMoreTools": { + "message": "Premium, güvəndə qalmağınız, səmərəli çalışmağınız və nəzarətə sahib olmağınız üçün daha çox alət verir." + }, + "explorePremium": { + "message": "Premium-u kəşf et" + }, "loadingVault": { "message": "Seyf yüklənir" }, diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index 450fb6e3df5..16a6d739962 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Прызначыць асноўны пароль" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index 0a71e453c21..3dba65d6aa7 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -595,7 +595,7 @@ "message": "Показване на всички" }, "viewLess": { - "message": "View less" + "message": "Преглед на по-малко" }, "viewLogin": { "message": "Преглед на елемента за вписване" @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Задаване на главна парола" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Надградете до Платения план" }, + "upgradeCompleteSecurity": { + "message": "Надградете, за да се възползвате от пълна защита" + }, + "premiumGivesMoreTools": { + "message": "Платеният план предоставя повече инструменти за защита, ефективна работа и контрол." + }, + "explorePremium": { + "message": "Разгледайте платения план" + }, "loadingVault": { "message": "Зареждане на трезора" }, diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index f43e3fdad29..d2519cb13e3 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "মূল পাসওয়ার্ড ধার্য করুন" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index 4fbcccd9aae..917180579f2 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 15a309fa8fd..a9ebdf139d7 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Estableix la contrasenya mestra" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index f9f572d87d8..ca5d4b09f28 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Tato stránka narušuje zážitek z Bitwardenu. Vložené menu Bitwarden bylo dočasně vypnuto jako bezpečnostní opatření." + }, "setMasterPassword": { "message": "Nastavit hlavní heslo" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Aktualizovat na Premium" }, + "upgradeCompleteSecurity": { + "message": "Aktualizujte pro úplné zabezpečení" + }, + "premiumGivesMoreTools": { + "message": "Verze Premium Vám poskytne více nástrojů k zabezpečení, efektivní práci a udržení kontroly." + }, + "explorePremium": { + "message": "Objevit Premium" + }, "loadingVault": { "message": "Načítání trezoru" }, diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 33c68b338a0..1f83ff72f62 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Gosod prif gyfrinair" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index a5999945692..96aa81ce876 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Indstil hovedadgangskode" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index b72cc2decb4..b32c9a68c06 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -595,7 +595,7 @@ "message": "Alles anzeigen" }, "viewLess": { - "message": "View less" + "message": "Weniger anzeigen" }, "viewLogin": { "message": "Zugangsdaten anzeigen" @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Master-Passwort festlegen" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade auf Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade für umfassende Sicherheit" + }, + "premiumGivesMoreTools": { + "message": "Premium gibt dir mehr Werkzeuge, um sicher zu bleiben, effizient zu arbeiten und die Kontrolle zu behalten." + }, + "explorePremium": { + "message": "Premium erkunden" + }, "loadingVault": { "message": "Tresor wird geladen" }, diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index e0de7e5e9e0..f4c3c0d53a5 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Ορισμός κύριου κωδικού πρόσβασης" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index f793b24a0e9..5cc7c30bfb4 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index a9c57e157e6..96c3323faef 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index cd8c91f8437..b9f777148e3 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index 470cf2ab35a..92dbe15fad2 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Establecer contraseña maestra" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index 9220d61e466..bb029bf7777 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Määra ülemparool" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index c360bed28e0..06a4f8ea48d 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Ezarri pasahitz nagusia" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 774a02f50d3..33f4a02277d 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "تنظیم کلمه عبور اصلی" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 8766632a91e..9953782f504 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Aseta pääsalasana" }, @@ -4984,7 +4987,7 @@ } }, "defaultLabelWithValue": { - "message": "Default ( $VALUE$ )", + "message": "Oletus ( $VALUE$ )", "description": "A label that indicates the default value for a field with the current default value in parentheses.", "placeholders": { "value": { @@ -5656,7 +5659,7 @@ "message": "Close this tab" }, "phishingPageContinueV2": { - "message": "Continue to this site (not recommended)" + "message": "Jatka tälle sivustolle (ei suositeltavaa)" }, "phishingPageExplanation1": { "message": "This site was found in ", @@ -5773,7 +5776,7 @@ "message": "Show less" }, "next": { - "message": "Next" + "message": "Seuraava" }, "moreBreadcrumbs": { "message": "More breadcrumbs", @@ -5809,21 +5812,30 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { - "message": "Loading vault" + "message": "Ladataan holvia" }, "vaultLoaded": { - "message": "Vault loaded" + "message": "Holvi ladattu" }, "settingDisabledByPolicy": { "message": "This setting is disabled by your organization's policy.", "description": "This hint text is displayed when a user setting is disabled due to an organization policy." }, "zipPostalCodeLabel": { - "message": "ZIP / Postal code" + "message": "Postinumero" }, "cardNumberLabel": { - "message": "Card number" + "message": "Kortin numero" }, "sessionTimeoutSettingsAction": { "message": "Timeout action" diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index 6c7154a1ba5..687863550a7 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Itakda ang master password" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 0e701750c5b..87c1a20a38a 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -32,7 +32,7 @@ "message": "Utiliser l'authentification unique" }, "yourOrganizationRequiresSingleSignOn": { - "message": "Your organization requires single sign-on." + "message": "Votre organisation exige l’authentification unique." }, "welcomeBack": { "message": "Content de vous revoir" @@ -592,10 +592,10 @@ "message": "Afficher" }, "viewAll": { - "message": "View all" + "message": "Tout afficher" }, "viewLess": { - "message": "View less" + "message": "Afficher moins" }, "viewLogin": { "message": "Afficher l'Identifiant" @@ -800,10 +800,10 @@ "message": "Au verrouillage" }, "onIdle": { - "message": "On system idle" + "message": "À l'inactivité du système" }, "onSleep": { - "message": "On system sleep" + "message": "À la mise en veille du système" }, "onRestart": { "message": "Au redémarrage du navigateur" @@ -1044,10 +1044,10 @@ "message": "Élément enregistré" }, "savedWebsite": { - "message": "Saved website" + "message": "Site Web enregistré" }, "savedWebsites": { - "message": "Saved websites ( $COUNT$ )", + "message": "Sites Web enregistrés ( $COUNT$)", "placeholders": { "count": { "content": "$1", @@ -1645,7 +1645,7 @@ "message": "Vous devez ajouter soit l'URL du serveur de base, soit au moins un environnement personnalisé." }, "selfHostedEnvMustUseHttps": { - "message": "URLs must use HTTPS." + "message": "Les URL doivent utiliser HTTPS." }, "customEnvironment": { "message": "Environnement personnalisé" @@ -1701,28 +1701,28 @@ "message": "Désactiver la saisie automatique" }, "confirmAutofill": { - "message": "Confirm autofill" + "message": "Confirmer la saisie automatique" }, "confirmAutofillDesc": { - "message": "This site doesn't match your saved login details. Before you fill in your login credentials, make sure it's a trusted site." + "message": "Ce site ne correspond pas à vos identifiants de connexion enregistrés. Avant de remplir vos identifiants de connexion, assurez-vous que c'est un site de confiance." }, "showInlineMenuLabel": { "message": "Afficher les suggestions de saisie automatique dans les champs d'un formulaire" }, "howDoesBitwardenProtectFromPhishing": { - "message": "How does Bitwarden protect your data from phishing?" + "message": "Comment Bitwarden protège-t-il vos données contre l'hameçonnage ?" }, "currentWebsite": { - "message": "Current website" + "message": "Site internet actuel" }, "autofillAndAddWebsite": { - "message": "Autofill and add this website" + "message": "Saisir automatiquement et ajouter ce site" }, "autofillWithoutAdding": { - "message": "Autofill without adding" + "message": "Saisir automatiquement sans ajouter" }, "doNotAutofill": { - "message": "Do not autofill" + "message": "Ne pas saisir automatiquement" }, "showInlineMenuIdentitiesLabel": { "message": "Afficher les identités sous forme de suggestions" @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Définir le mot de passe principal" }, @@ -3286,7 +3289,7 @@ "message": "Erreur de déchiffrement" }, "errorGettingAutoFillData": { - "message": "Error getting autofill data" + "message": "Erreur lors de l'obtention des données de saisie automatique" }, "couldNotDecryptVaultItemsBelow": { "message": "Bitwarden n’a pas pu déchiffrer le(s) élément(s) du coffre listé(s) ci-dessous." @@ -4060,13 +4063,13 @@ "description": "Toast message for informing the user that autofill on page load has been set to the default setting." }, "cannotAutofill": { - "message": "Cannot autofill" + "message": "Impossible de saisir automatiquement" }, "cannotAutofillExactMatch": { - "message": "Default matching is set to 'Exact Match'. The current website does not exactly match the saved login details for this item." + "message": "La correspondance par défaut est définie à 'Correspondance exacte'. Le site internet actuel ne correspond pas exactement aux informations de l'identifiant de connexion enregistrées pour cet élément." }, "okay": { - "message": "Okay" + "message": "Ok" }, "toggleSideNavigation": { "message": "Basculer la navigation latérale" @@ -4984,7 +4987,7 @@ } }, "defaultLabelWithValue": { - "message": "Default ( $VALUE$ )", + "message": "Par défaut ($VALUE$)", "description": "A label that indicates the default value for a field with the current default value in parentheses.", "placeholders": { "value": { @@ -5786,34 +5789,43 @@ "message": "Excellent travail pour sécuriser vos identifiants à risque !" }, "upgradeNow": { - "message": "Upgrade now" + "message": "Mettre à niveau maintenant" }, "builtInAuthenticator": { - "message": "Built-in authenticator" + "message": "Authentificateur intégré" }, "secureFileStorage": { - "message": "Secure file storage" + "message": "Stockage sécurisé de fichier" }, "emergencyAccess": { - "message": "Emergency access" + "message": "Accès d'urgence" }, "breachMonitoring": { - "message": "Breach monitoring" + "message": "Surveillance des fuites" }, "andMoreFeatures": { - "message": "And more!" + "message": "Et encore plus !" }, "planDescPremium": { - "message": "Complete online security" + "message": "Sécurité en ligne complète" }, "upgradeToPremium": { - "message": "Upgrade to Premium" + "message": "Mettre à niveau vers Premium" + }, + "upgradeCompleteSecurity": { + "message": "Mettre à niveau pour une sécurité complète" + }, + "premiumGivesMoreTools": { + "message": "Premium vous donne plus d'outils pour rester en sécurité, travailler efficacement et garder le contrôle." + }, + "explorePremium": { + "message": "Explorer Premium" }, "loadingVault": { - "message": "Loading vault" + "message": "Chargement du coffre" }, "vaultLoaded": { - "message": "Vault loaded" + "message": "Coffre chargé" }, "settingDisabledByPolicy": { "message": "Ce paramètre est désactivé par la politique de sécurité de votre organisation.", @@ -5826,6 +5838,6 @@ "message": "Numéro de carte" }, "sessionTimeoutSettingsAction": { - "message": "Timeout action" + "message": "Action à l’expiration" } } diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index c61325ef8de..9b35af1aad4 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Definir contrasinal mestre" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index 81ac1e176f0..7d1700cbbdc 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -595,7 +595,7 @@ "message": "הצג הכל" }, "viewLess": { - "message": "View less" + "message": "הצג פחות" }, "viewLogin": { "message": "הצג כניסה" @@ -800,10 +800,10 @@ "message": "בנעילת המערכת" }, "onIdle": { - "message": "On system idle" + "message": "כשהמערכת מזהה חוסר פעילות" }, "onSleep": { - "message": "On system sleep" + "message": "כשהמערכת נכנסת למצב שינה" }, "onRestart": { "message": "בהפעלת הדפדפן מחדש" @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "הגדר סיסמה ראשית" }, @@ -4984,7 +4987,7 @@ } }, "defaultLabelWithValue": { - "message": "Default ( $VALUE$ )", + "message": "ברירת מחדל ( $VALUE$ )", "description": "A label that indicates the default value for a field with the current default value in parentheses.", "placeholders": { "value": { @@ -5786,34 +5789,43 @@ "message": "עבודה נהדרת באבטחת הכניסות בסיכון שלך!" }, "upgradeNow": { - "message": "Upgrade now" + "message": "שדרג עכשיו" }, "builtInAuthenticator": { - "message": "Built-in authenticator" + "message": "מאמת מובנה" }, "secureFileStorage": { - "message": "Secure file storage" + "message": "אחסון קבצים מאובטח" }, "emergencyAccess": { - "message": "Emergency access" + "message": "גישת חירום" }, "breachMonitoring": { - "message": "Breach monitoring" + "message": "ניטור פרצות" }, "andMoreFeatures": { - "message": "And more!" + "message": "ועוד!" }, "planDescPremium": { - "message": "Complete online security" + "message": "השלם אבטחה מקוונת" }, "upgradeToPremium": { - "message": "Upgrade to Premium" + "message": "שדרג לפרימיום" + }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" }, "loadingVault": { - "message": "Loading vault" + "message": "טוען כספת" }, "vaultLoaded": { - "message": "Vault loaded" + "message": "הכספת נטענה" }, "settingDisabledByPolicy": { "message": "הגדרה זו מושבתת על ידי מדיניות של הארגון שלך.", @@ -5826,6 +5838,6 @@ "message": "מספר כרטיס" }, "sessionTimeoutSettingsAction": { - "message": "Timeout action" + "message": "פעולת פסק זמן" } } diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index ff24818f821..0af38bf6964 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "मास्टर पासवर्ड सेट करें" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 052fae33683..9bb5ca08843 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Postavi glavnu lozinku" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": " Nadogradi na Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Učitavanje trezora" }, diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index fb94c4f4665..9b6a5d756d5 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Ez az oldal zavarja a Bitwarden élményt. Biztonsági intézkedésként ideiglenesen letiltásra került a Bitwarden belső menü." + }, "setMasterPassword": { "message": "Mesterjelszó beállítása" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Áttérés Prémium csomagra" }, + "upgradeCompleteSecurity": { + "message": "Áttérés a teljes biztonságért" + }, + "premiumGivesMoreTools": { + "message": "A Premium több eszközt ad a biztonság megőrzéséhez, a hatékony munkavégzéshez és az irányítás megőrzéséhez." + }, + "explorePremium": { + "message": "Premium felfedezése" + }, "loadingVault": { "message": "Széf betöltése" }, diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index 1cb79804923..85fdfbf9afe 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Atur Kata Sandi Utama" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index dc69cb13cb3..a76bb05d15a 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Imposta password principale" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 8b3b0e2cc6d..1294335481c 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "マスターパスワードを設定" }, @@ -3365,7 +3368,7 @@ "message": "サービス" }, "forwardedEmail": { - "message": "転送されたメールエイリアス" + "message": "転送されるメールエイリアス" }, "forwardedEmailDesc": { "message": "外部転送サービスを使用してメールエイリアスを生成します。" @@ -4517,7 +4520,7 @@ "description": "Label indicating the most common import formats" }, "uriMatchDefaultStrategyHint": { - "message": "URI match detection is how Bitwarden identifies autofill suggestions.", + "message": "URI の一致検出方法は、Bitwarden が自動入力候補をどのように判別するかを指定します。", "description": "Explains to the user that URI match detection determines how Bitwarden suggests autofill options, and clarifies that this default strategy applies when no specific match detection is set for a login item." }, "regExAdvancedOptionWarning": { @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "プレミアムにアップグレード" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index 5c7a8da23a7..52e9fbc5229 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index 13e74f8d807..7c4dbaf85dc 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 3e929bc6533..d2ca68a0108 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "ಮಾಸ್ಟರ್ ಪಾಸ್ವರ್ಡ್ ಹೊಂದಿಸಿ" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 5a21928c233..c583e173d91 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "마스터 비밀번호 설정" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index ac598394a8c..45ee71f75dd 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Pagrindinio slaptažodžio nustatymas" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index 70f46e0f068..d7e4b5eea9c 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Šī lapa traucē Bitwarden darbību. Bitwarden iekļautā izvēlne ir īslaicīgi atspējot kā drošības mērs." + }, "setMasterPassword": { "message": "Uzstādīt galveno paroli" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Uzlabot uz Premium" }, + "upgradeCompleteSecurity": { + "message": "Uzlabo pilnīgas drošības iegūšanai" + }, + "premiumGivesMoreTools": { + "message": "Premium sniedz vairāk rīku drošībai, darba ražīgumam un pārraudzībai." + }, + "explorePremium": { + "message": "Izpētīt Premium" + }, "loadingVault": { "message": "Ielādē glabātavu" }, diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index d139531315b..6c022f0043f 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "പ്രാഥമിക പാസ്‌വേഡ് സജ്ജമാക്കുക" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 438cc750557..d34d1c87971 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index 13e74f8d807..7c4dbaf85dc 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 4ae8a01a12f..11bd78ed56c 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Angi hovedpassord" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index 13e74f8d807..7c4dbaf85dc 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index b3463c9f1b3..8817e04b163 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Deze pagina verstoort de Bitwarden-ervaring. Het inline-menu van Bitwarden is tijdelijk uitgeschakeld als veiligheidsmaatregel." + }, "setMasterPassword": { "message": "Hoofdwachtwoord instellen" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Opwaarderen naar Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade voor volledige beveiliging" + }, + "premiumGivesMoreTools": { + "message": "Premium geeft je meer tools om veilig te blijven, efficiënt te werken en in controle te blijven." + }, + "explorePremium": { + "message": "Premium verkennen" + }, "loadingVault": { "message": "Kluis laden" }, diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index 13e74f8d807..7c4dbaf85dc 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index 13e74f8d807..7c4dbaf85dc 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index 77b6cc436d7..41f679aba50 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Ustaw hasło główne" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index c3d96145944..9e5d2331744 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Definir senha principal" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Faça upgrade para o Premium" }, + "upgradeCompleteSecurity": { + "message": "Faça upgrade para segurança completa" + }, + "premiumGivesMoreTools": { + "message": "O Premium te oferece mais ferramentas para se permanecer seguro, trabalhar eficientemente, e manter o controle." + }, + "explorePremium": { + "message": "Explorar o Premium" + }, "loadingVault": { "message": "Carregando cofre" }, diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 10fbc3db004..4fd291e5c89 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Esta página está a interferir com a experiência do Bitwarden. O menu em linha do Bitwarden foi temporariamente desativado como medida de segurança." + }, "setMasterPassword": { "message": "Definir a palavra-passe mestra" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Atualizar para o Premium" }, + "upgradeCompleteSecurity": { + "message": "Atualize para obter segurança total" + }, + "premiumGivesMoreTools": { + "message": "O Premium oferece mais ferramentas para manter a segurança, trabalhar com eficiência e manter o controlo." + }, + "explorePremium": { + "message": "Explorar o Premium" + }, "loadingVault": { "message": "A carregar o cofre" }, diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 5fe7c61f9cc..66a5b9d796b 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Setare parolă principală" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 349e68c5194..1f3d7c7234f 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Эта страница мешает работе Bitwarden. Встроенное меню Bitwarden было временно отключено в целях безопасности." + }, "setMasterPassword": { "message": "Задать мастер-пароль" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Обновить до Премиум" }, + "upgradeCompleteSecurity": { + "message": "Перейти для полной защищенности" + }, + "premiumGivesMoreTools": { + "message": "Премиум предоставит вам больше инструментов для обеспечения безопасности, эффективной работы и контроля над ситуацией." + }, + "explorePremium": { + "message": "Познакомиться с Премиум" + }, "loadingVault": { "message": "Загрузка хранилища" }, diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index 9b36684dc5a..61dc029754a 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "මාස්ටර් මුරපදය සකසන්න" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index a269756a414..865e832fda3 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -2195,7 +2195,7 @@ "description": "Default URI match detection for autofill." }, "toggleOptions": { - "message": "Voľby prepínača" + "message": "Zobraziť/skryť možnosti" }, "toggleCurrentUris": { "message": "Prepnúť zobrazenie aktuálnej URI", @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Táto stránka narúša zážitok zo Bitwardenu. Inline ponuka Bitwardenu bola dočasne vypnutá ako bezpečnostné opatrenie." + }, "setMasterPassword": { "message": "Nastaviť hlavné heslo" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgradovať na Prémium" }, + "upgradeCompleteSecurity": { + "message": "Upgradovať pre úplné zabezpečenie" + }, + "premiumGivesMoreTools": { + "message": "Predplatné Prémium vám poskytuje viac nástrojov na zabezpečenie, efektívnu prácu a kontrolu." + }, + "explorePremium": { + "message": "Preskúmať Prémium" + }, "loadingVault": { "message": "Načítava sa trezor" }, diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 3cbd9a11342..ebb245290f9 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Nastavi glavno geslo" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index d13939f8656..d54a6ba928f 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Постави Главну Лозинку" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Надоградите на Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index 9f84e9d714c..245692a27aa 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -595,7 +595,7 @@ "message": "Visa alla" }, "viewLess": { - "message": "View less" + "message": "Visa mindre" }, "viewLogin": { "message": "Visa inloggning" @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Denna sida stör Bitwarden-upplevelsen. Bitwardens inbyggda meny har tillfälligt inaktiverats som en säkerhetsåtgärd." + }, "setMasterPassword": { "message": "Ange huvudlösenord" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Uppgradera till Premium" }, + "upgradeCompleteSecurity": { + "message": "Uppgradera för fullständig säkerhet" + }, + "premiumGivesMoreTools": { + "message": "Premium ger dig fler verktyg för att hålla dig säker, arbeta effektivt och ha kontroll." + }, + "explorePremium": { + "message": "Utforska Premium" + }, "loadingVault": { "message": "Läser in valv" }, diff --git a/apps/browser/src/_locales/ta/messages.json b/apps/browser/src/_locales/ta/messages.json index cbefd26424c..a6e2ad0ee31 100644 --- a/apps/browser/src/_locales/ta/messages.json +++ b/apps/browser/src/_locales/ta/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "முதன்மை கடவுச்சொல்லை அமை" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index 13e74f8d807..7c4dbaf85dc 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Set master password" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index 594bc6d7a94..ff0c05a470a 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "ตั้งรหัสผ่านหลัก" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Upgrade to Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 7f234b8750a..b2bff83e8a9 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -595,7 +595,7 @@ "message": "Tümünü göster" }, "viewLess": { - "message": "View less" + "message": "Daha az göster" }, "viewLogin": { "message": "Hesabı göster" @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "Bu sayfa Bitwarden deneyimiyle çakışıyor. Güvenlik önlemi olarak Bitwarden satır içi menüsü geçici olarak devre dışı bırakıldı." + }, "setMasterPassword": { "message": "Ana parolayı belirle" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Premium'a yükselt" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Kasa yükleniyor" }, diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index a17033ee6e8..b104f845fa7 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Встановити головний пароль" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Покращити до Premium" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 2fdba62adeb..242b779ca26 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "This page is interfering with the Bitwarden experience. The Bitwarden inline menu has been temporarily disabled as a safety measure." + }, "setMasterPassword": { "message": "Đặt mật khẩu chính" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "Nâng cấp lên gói Cao cấp" }, + "upgradeCompleteSecurity": { + "message": "Upgrade for complete security" + }, + "premiumGivesMoreTools": { + "message": "Premium gives you more tools to stay secure, work efficiently, and stay in control." + }, + "explorePremium": { + "message": "Explore Premium" + }, "loadingVault": { "message": "Loading vault" }, diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 52d8a03b769..cf1664b6a6f 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -595,7 +595,7 @@ "message": "查看全部" }, "viewLess": { - "message": "View less" + "message": "查看更少" }, "viewLogin": { "message": "查看登录" @@ -1485,7 +1485,7 @@ "message": "优先客户支持。" }, "ppremiumSignUpFuture": { - "message": "未来的更多高级功能。敬请期待!" + "message": "未来的更多高级版功能。敬请期待!" }, "premiumPurchase": { "message": "购买高级版" @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "此页面正在干扰 Bitwarden 的使用体验。出于安全考虑,Bitwarden 内嵌菜单已被暂时禁用。" + }, "setMasterPassword": { "message": "设置主密码" }, @@ -4897,7 +4900,7 @@ "message": "确定要永久删除此附件吗?" }, "premium": { - "message": "高级会员" + "message": "高级版" }, "freeOrgsCannotUseAttachments": { "message": "免费组织无法使用附件" @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "升级为高级版" }, + "upgradeCompleteSecurity": { + "message": "升级以获得全面的安全防护" + }, + "premiumGivesMoreTools": { + "message": "高级版为您提供更多工具,助您保障安全、高效工作并掌控一切。" + }, + "explorePremium": { + "message": "探索高级版" + }, "loadingVault": { "message": "正在加载密码库" }, diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index 370c147871b..0ef20edce81 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -595,7 +595,7 @@ "message": "檢視全部" }, "viewLess": { - "message": "View less" + "message": "顯示較少" }, "viewLogin": { "message": "檢視登入" @@ -2436,6 +2436,9 @@ } } }, + "topLayerHijackWarning": { + "message": "此頁面正在干擾 Bitwarden 的使用體驗。為了安全起見,已暫時停用 Bitwarden 的內嵌選單。" + }, "setMasterPassword": { "message": "設定主密碼" }, @@ -5809,6 +5812,15 @@ "upgradeToPremium": { "message": "升級到 Premium" }, + "upgradeCompleteSecurity": { + "message": "升級以獲得完整的安全防護" + }, + "premiumGivesMoreTools": { + "message": "進階版提供更多工具,協助您維持安全、高效工作並保持掌控。" + }, + "explorePremium": { + "message": "探索進階版" + }, "loadingVault": { "message": "正在載入密碼庫" }, diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 80e453e9e83..50fb291b121 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -3286,6 +3286,9 @@ describe("OverlayBackground", () => { pageDetails: [pageDetailsForTab], fillNewPassword: true, allowTotpAutofill: true, + focusedFieldForm: undefined, + focusedFieldOpid: undefined, + inlineMenuFillType: undefined, }); expect(overlayBackground["inlineMenuCiphers"].entries()).toStrictEqual( new Map([ @@ -3680,6 +3683,9 @@ describe("OverlayBackground", () => { pageDetails: [overlayBackground["pageDetailsForTab"][sender.tab.id].get(sender.frameId)], fillNewPassword: true, allowTotpAutofill: false, + focusedFieldForm: undefined, + focusedFieldOpid: undefined, + inlineMenuFillType: InlineMenuFillTypes.PasswordGeneration, }); }); }); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index f3278fa6b07..225cbbe66ca 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -1177,6 +1177,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { allowTotpAutofill: true, focusedFieldForm: this.focusedFieldData?.focusedFieldForm, focusedFieldOpid: this.focusedFieldData?.focusedFieldOpid, + inlineMenuFillType: this.focusedFieldData?.inlineMenuFillType, }); if (totpCode) { @@ -1863,6 +1864,7 @@ export class OverlayBackground implements OverlayBackgroundInterface { allowTotpAutofill: false, focusedFieldForm: this.focusedFieldData?.focusedFieldForm, focusedFieldOpid: this.focusedFieldData?.focusedFieldOpid, + inlineMenuFillType: InlineMenuFillTypes.PasswordGeneration, }); globalThis.setTimeout(async () => { diff --git a/apps/browser/src/autofill/content/content-message-handler.ts b/apps/browser/src/autofill/content/content-message-handler.ts index c57b2d959f3..63afc215923 100644 --- a/apps/browser/src/autofill/content/content-message-handler.ts +++ b/apps/browser/src/autofill/content/content-message-handler.ts @@ -86,17 +86,30 @@ function handleOpenBrowserExtensionToUrlMessage({ url }: { url?: ExtensionPageUr } /** - * Handles the window message event. + * Handles window message events, validating source and extracting referrer for security. * * @param event - The window message event */ function handleWindowMessageEvent(event: MessageEvent) { - const { source, data } = event; + const { source, data, origin } = event; if (source !== window || !data?.command) { return; } - const referrer = source.location.hostname; + // Extract hostname from event.origin for secure referrer validation in background script + let referrer: string; + // Sandboxed iframe or opaque origin support + if (origin === "null") { + referrer = "null"; + } else { + try { + const originUrl = new URL(origin); + referrer = originUrl.hostname; + } catch { + return; + } + } + const handler = windowMessageHandlers[data.command]; if (handler) { handler({ data, referrer }); diff --git a/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts b/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts index 6ad069ad56e..b341be28ebb 100644 --- a/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts +++ b/apps/browser/src/autofill/fido2/background/abstractions/fido2.background.ts @@ -13,6 +13,7 @@ type SharedFido2ScriptRegistrationOptions = SharedFido2ScriptInjectionDetails & matches: string[]; excludeMatches: string[]; allFrames: true; + world?: "MAIN" | "ISOLATED"; }; type Fido2ExtensionMessage = { diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts index 752851b3d37..adb59b8f845 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.spec.ts @@ -203,6 +203,7 @@ describe("Fido2Background", () => { { file: Fido2ContentScript.PageScriptDelayAppend }, { file: Fido2ContentScript.ContentScript }, ], + world: "MAIN", ...sharedRegistrationOptions, }); }); diff --git a/apps/browser/src/autofill/fido2/background/fido2.background.ts b/apps/browser/src/autofill/fido2/background/fido2.background.ts index 22ee4a1822d..a8b016a14d6 100644 --- a/apps/browser/src/autofill/fido2/background/fido2.background.ts +++ b/apps/browser/src/autofill/fido2/background/fido2.background.ts @@ -176,6 +176,7 @@ export class Fido2Background implements Fido2BackgroundInterface { { file: await this.getFido2PageScriptAppendFileName() }, { file: Fido2ContentScript.ContentScript }, ], + world: "MAIN", ...this.sharedRegistrationOptions, }); } diff --git a/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts b/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts index 775bc76266d..e167f30af0a 100644 --- a/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts +++ b/apps/browser/src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts @@ -8,6 +8,9 @@ } const script = globalContext.document.createElement("script"); + // This script runs in world: MAIN, eliminating the risk associated with this lint error. + // DOM injection is still needed for the iframe timing hack. + // eslint-disable-next-line @bitwarden/platform/no-page-script-url-leakage script.src = chrome.runtime.getURL("content/fido2-page-script.js"); script.async = false; diff --git a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-container.ts b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-container.ts index a147e0ba165..af60d1de77d 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-container.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/abstractions/autofill-inline-menu-container.ts @@ -5,6 +5,7 @@ import { InlineMenuCipherData } from "../../../background/abstractions/overlay.b export type AutofillInlineMenuContainerMessage = { command: string; portKey: string; + token?: string; }; export type InitAutofillInlineMenuElementMessage = AutofillInlineMenuContainerMessage & { diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts index f1a74556b24..b7bd24c537b 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.spec.ts @@ -53,13 +53,35 @@ describe("AutofillInlineMenuContentService", () => { }); }); + describe("messageHandlers", () => { + it("returns the extension message handlers", () => { + const handlers = autofillInlineMenuContentService.messageHandlers; + + expect(handlers).toHaveProperty("closeAutofillInlineMenu"); + expect(handlers).toHaveProperty("appendAutofillInlineMenuToDom"); + }); + }); + describe("isElementInlineMenu", () => { - it("returns true if the passed element is the inline menu", () => { + it("returns true if the passed element is the inline menu list", () => { const element = document.createElement("div"); autofillInlineMenuContentService["listElement"] = element; expect(autofillInlineMenuContentService.isElementInlineMenu(element)).toBe(true); }); + + it("returns true if the passed element is the inline menu button", () => { + const element = document.createElement("div"); + autofillInlineMenuContentService["buttonElement"] = element; + + expect(autofillInlineMenuContentService.isElementInlineMenu(element)).toBe(true); + }); + + it("returns false if the passed element is not the inline menu", () => { + const element = document.createElement("div"); + + expect(autofillInlineMenuContentService.isElementInlineMenu(element)).toBe(false); + }); }); describe("extension message handlers", () => { @@ -388,7 +410,7 @@ describe("AutofillInlineMenuContentService", () => { }); it("closes the inline menu if the page body is not sufficiently opaque", async () => { - document.querySelector("html").style.opacity = "0.9"; + document.documentElement.style.opacity = "0.9"; document.body.style.opacity = "0"; await autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]); @@ -397,7 +419,7 @@ describe("AutofillInlineMenuContentService", () => { }); it("closes the inline menu if the page html is not sufficiently opaque", async () => { - document.querySelector("html").style.opacity = "0.3"; + document.documentElement.style.opacity = "0.3"; document.body.style.opacity = "0.7"; await autofillInlineMenuContentService["handlePageMutations"]([mockHTMLMutationRecord]); @@ -406,7 +428,7 @@ describe("AutofillInlineMenuContentService", () => { }); it("does not close the inline menu if the page html and body is sufficiently opaque", async () => { - document.querySelector("html").style.opacity = "0.9"; + document.documentElement.style.opacity = "0.9"; document.body.style.opacity = "1"; await autofillInlineMenuContentService["handlePageMutations"]([mockBodyMutationRecord]); await waitForIdleCallback(); @@ -599,5 +621,465 @@ describe("AutofillInlineMenuContentService", () => { overlayElement: AutofillOverlayElement.List, }); }); + + it("clears the persistent last child override timeout", () => { + jest.useFakeTimers(); + const clearTimeoutSpy = jest.spyOn(globalThis, "clearTimeout"); + autofillInlineMenuContentService["handlePersistentLastChildOverrideTimeout"] = setTimeout( + jest.fn(), + 500, + ); + + autofillInlineMenuContentService.destroy(); + + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + + it("unobserves page attributes", () => { + const disconnectSpy = jest.spyOn( + autofillInlineMenuContentService["htmlMutationObserver"], + "disconnect", + ); + + autofillInlineMenuContentService.destroy(); + + expect(disconnectSpy).toHaveBeenCalled(); + }); + }); + + describe("getOwnedTagNames", () => { + it("returns an empty array when no elements are created", () => { + expect(autofillInlineMenuContentService.getOwnedTagNames()).toEqual([]); + }); + + it("returns the button element tag name", () => { + const buttonElement = document.createElement("div"); + autofillInlineMenuContentService["buttonElement"] = buttonElement; + + const tagNames = autofillInlineMenuContentService.getOwnedTagNames(); + + expect(tagNames).toContain("DIV"); + }); + + it("returns both button and list element tag names", () => { + const buttonElement = document.createElement("div"); + const listElement = document.createElement("span"); + autofillInlineMenuContentService["buttonElement"] = buttonElement; + autofillInlineMenuContentService["listElement"] = listElement; + + const tagNames = autofillInlineMenuContentService.getOwnedTagNames(); + + expect(tagNames).toEqual(["DIV", "SPAN"]); + }); + }); + + describe("getUnownedTopLayerItems", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("returns the tag names from button and list elements", () => { + const buttonElement = document.createElement("div"); + buttonElement.setAttribute("popover", "manual"); + autofillInlineMenuContentService["buttonElement"] = buttonElement; + + const listElement = document.createElement("span"); + listElement.setAttribute("popover", "manual"); + autofillInlineMenuContentService["listElement"] = listElement; + + /** Mock querySelectorAll to avoid :modal selector issues in jsdom */ + const querySelectorAllSpy = jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([] as any); + + const items = autofillInlineMenuContentService.getUnownedTopLayerItems(); + + expect(querySelectorAllSpy).toHaveBeenCalled(); + expect(items.length).toBe(0); + }); + + it("calls querySelectorAll with correct selector when includeCandidates is false", () => { + /** Mock querySelectorAll to avoid :modal selector issues in jsdom */ + const querySelectorAllSpy = jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([] as any); + + autofillInlineMenuContentService.getUnownedTopLayerItems(false); + + const calledSelector = querySelectorAllSpy.mock.calls[0][0]; + expect(calledSelector).toContain(":modal"); + expect(calledSelector).toContain(":popover-open"); + }); + + it("includes candidates selector when requested", () => { + /** Mock querySelectorAll to avoid :modal selector issues in jsdom */ + const querySelectorAllSpy = jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([] as any); + + autofillInlineMenuContentService.getUnownedTopLayerItems(true); + + const calledSelector = querySelectorAllSpy.mock.calls[0][0]; + expect(calledSelector).toContain("[popover], dialog"); + }); + }); + + describe("refreshTopLayerPosition", () => { + it("does nothing when inline menu is disabled", () => { + const getUnownedTopLayerItemsSpy = jest.spyOn( + autofillInlineMenuContentService, + "getUnownedTopLayerItems", + ); + + autofillInlineMenuContentService["inlineMenuEnabled"] = false; + const buttonElement = document.createElement("div"); + autofillInlineMenuContentService["buttonElement"] = buttonElement; + + autofillInlineMenuContentService.refreshTopLayerPosition(); + + // Should exit early and not call `getUnownedTopLayerItems` + expect(getUnownedTopLayerItemsSpy).not.toHaveBeenCalled(); + }); + + it("does nothing when no other top layer items exist", () => { + const buttonElement = document.createElement("div"); + autofillInlineMenuContentService["buttonElement"] = buttonElement; + jest + .spyOn(autofillInlineMenuContentService, "getUnownedTopLayerItems") + .mockReturnValue([] as any); + + const getElementsByTagSpy = jest.spyOn(globalThis.document, "getElementsByTagName"); + + autofillInlineMenuContentService.refreshTopLayerPosition(); + + // Should exit early and not get inline elements to refresh + expect(getElementsByTagSpy).not.toHaveBeenCalled(); + }); + + it("refreshes button popover when button is in document", () => { + jest + .spyOn(autofillInlineMenuContentService, "getUnownedTopLayerItems") + .mockReturnValue([document.createElement("div")] as any); + + const buttonElement = document.createElement("div"); + buttonElement.setAttribute("popover", "manual"); + buttonElement.showPopover = jest.fn(); + buttonElement.hidePopover = jest.fn(); + document.body.appendChild(buttonElement); + autofillInlineMenuContentService["buttonElement"] = buttonElement; + + autofillInlineMenuContentService.refreshTopLayerPosition(); + + expect(buttonElement.hidePopover).toHaveBeenCalled(); + expect(buttonElement.showPopover).toHaveBeenCalled(); + }); + + it("refreshes list popover when list is in document", () => { + jest + .spyOn(autofillInlineMenuContentService, "getUnownedTopLayerItems") + .mockReturnValue([document.createElement("div")] as any); + + const listElement = document.createElement("div"); + listElement.setAttribute("popover", "manual"); + listElement.showPopover = jest.fn(); + listElement.hidePopover = jest.fn(); + document.body.appendChild(listElement); + autofillInlineMenuContentService["listElement"] = listElement; + + autofillInlineMenuContentService.refreshTopLayerPosition(); + + expect(listElement.hidePopover).toHaveBeenCalled(); + expect(listElement.showPopover).toHaveBeenCalled(); + }); + }); + + describe("checkAndUpdateRefreshCount", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2023-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("does nothing when inline menu is disabled", () => { + autofillInlineMenuContentService["inlineMenuEnabled"] = false; + + autofillInlineMenuContentService["checkAndUpdateRefreshCount"]("topLayer"); + + expect(autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer).toBe(0); + }); + + it("increments refresh count when within time threshold", () => { + autofillInlineMenuContentService["lastTrackedTimestamp"].topLayer = Date.now() - 1000; + + autofillInlineMenuContentService["checkAndUpdateRefreshCount"]("topLayer"); + + expect(autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer).toBe(1); + }); + + it("resets count when outside time threshold", () => { + autofillInlineMenuContentService["lastTrackedTimestamp"].topLayer = Date.now() - 6000; + autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer = 5; + + autofillInlineMenuContentService["checkAndUpdateRefreshCount"]("topLayer"); + + expect(autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer).toBe(0); + }); + + it("disables inline menu and shows alert when count exceeds threshold", () => { + const alertSpy = jest.spyOn(globalThis.window, "alert").mockImplementation(); + const checkPageRisksSpy = jest.spyOn( + autofillInlineMenuContentService as any, + "checkPageRisks", + ); + autofillInlineMenuContentService["lastTrackedTimestamp"].topLayer = Date.now() - 1000; + autofillInlineMenuContentService["refreshCountWithinTimeThreshold"].topLayer = 6; + + autofillInlineMenuContentService["checkAndUpdateRefreshCount"]("topLayer"); + + expect(autofillInlineMenuContentService["inlineMenuEnabled"]).toBe(false); + expect(alertSpy).toHaveBeenCalled(); + expect(checkPageRisksSpy).toHaveBeenCalled(); + }); + }); + + describe("refreshPopoverAttribute", () => { + it("calls checkAndUpdateRefreshCount with popoverAttribute type", () => { + const checkSpy = jest.spyOn( + autofillInlineMenuContentService as any, + "checkAndUpdateRefreshCount", + ); + const element = document.createElement("div"); + element.setAttribute("popover", "auto"); + element.showPopover = jest.fn(); + + autofillInlineMenuContentService["refreshPopoverAttribute"](element); + + expect(checkSpy).toHaveBeenCalledWith("popoverAttribute"); + expect(element.getAttribute("popover")).toBe("manual"); + expect(element.showPopover).toHaveBeenCalled(); + }); + }); + + describe("handleInlineMenuElementMutationObserverUpdate - popover attribute", () => { + it("refreshes popover attribute when changed from manual", () => { + const element = document.createElement("div"); + element.setAttribute("popover", "auto"); + element.showPopover = jest.fn(); + const refreshSpy = jest.spyOn( + autofillInlineMenuContentService as any, + "refreshPopoverAttribute", + ); + autofillInlineMenuContentService["buttonElement"] = element; + + const mockMutation = createMutationRecordMock({ + target: element, + type: "attributes", + attributeName: "popover", + }); + + autofillInlineMenuContentService["handleInlineMenuElementMutationObserverUpdate"]([ + mockMutation, + ]); + + expect(refreshSpy).toHaveBeenCalledWith(element); + }); + + it("does not refresh popover attribute when already manual", () => { + const element = document.createElement("div"); + element.setAttribute("popover", "manual"); + const refreshSpy = jest.spyOn( + autofillInlineMenuContentService as any, + "refreshPopoverAttribute", + ); + autofillInlineMenuContentService["buttonElement"] = element; + + const mockMutation = createMutationRecordMock({ + target: element, + type: "attributes", + attributeName: "popover", + }); + + autofillInlineMenuContentService["handleInlineMenuElementMutationObserverUpdate"]([ + mockMutation, + ]); + + expect(refreshSpy).not.toHaveBeenCalled(); + }); + }); + + describe("appendInlineMenuElements when disabled", () => { + beforeEach(() => { + observeContainerMutationsSpy.mockImplementation(); + }); + + it("does not append button when inline menu is disabled", async () => { + autofillInlineMenuContentService["inlineMenuEnabled"] = false; + jest.spyOn(globalThis.document.body, "appendChild"); + + sendMockExtensionMessage({ + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }); + await flushPromises(); + + expect(globalThis.document.body.appendChild).not.toHaveBeenCalled(); + }); + + it("does not append list when inline menu is disabled", async () => { + autofillInlineMenuContentService["inlineMenuEnabled"] = false; + jest.spyOn(globalThis.document.body, "appendChild"); + + sendMockExtensionMessage({ + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }); + await flushPromises(); + + expect(globalThis.document.body.appendChild).not.toHaveBeenCalled(); + }); + }); + + describe("custom element creation for non-Firefox browsers", () => { + beforeEach(() => { + autofillInlineMenuContentService["isFirefoxBrowser"] = false; + observeContainerMutationsSpy.mockImplementation(); + }); + + it("creates a custom element for button in non-Firefox browsers", () => { + const definespy = jest.spyOn(globalThis.customElements, "define"); + + sendMockExtensionMessage({ + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.Button, + }); + + expect(definespy).toHaveBeenCalled(); + expect(autofillInlineMenuContentService["buttonElement"]).toBeDefined(); + expect(autofillInlineMenuContentService["buttonElement"]?.tagName).not.toBe("DIV"); + }); + + it("creates a custom element for list in non-Firefox browsers", () => { + const defineSpy = jest.spyOn(globalThis.customElements, "define"); + + sendMockExtensionMessage({ + command: "appendAutofillInlineMenuToDom", + overlayElement: AutofillOverlayElement.List, + }); + + expect(defineSpy).toHaveBeenCalled(); + expect(autofillInlineMenuContentService["listElement"]).toBeDefined(); + expect(autofillInlineMenuContentService["listElement"]?.tagName).not.toBe("DIV"); + }); + }); + + describe("getPageIsOpaque", () => { + it("returns false when no page elements exist", () => { + jest.spyOn(globalThis.document, "querySelectorAll").mockReturnValue([] as any); + + const result = autofillInlineMenuContentService["getPageIsOpaque"](); + + expect(result).toBe(false); + }); + + it("returns true when all html and body nodes have sufficient opacity", () => { + jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([document.documentElement, document.body] as any); + jest + .spyOn(globalThis.window, "getComputedStyle") + .mockImplementation(() => ({ opacity: "1" }) as CSSStyleDeclaration); + + const result = autofillInlineMenuContentService["getPageIsOpaque"](); + + expect(result).toBe(true); + }); + + it("returns false when html opacity is below threshold", () => { + jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([document.documentElement, document.body] as any); + let callCount = 0; + jest.spyOn(globalThis.window, "getComputedStyle").mockImplementation(() => { + callCount++; + return { opacity: callCount === 1 ? "0.5" : "1" } as CSSStyleDeclaration; + }); + + const result = autofillInlineMenuContentService["getPageIsOpaque"](); + + expect(result).toBe(false); + }); + + it("returns false when body opacity is below threshold", () => { + jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([document.documentElement, document.body] as any); + let callCount = 0; + jest.spyOn(globalThis.window, "getComputedStyle").mockImplementation(() => { + callCount++; + return { opacity: callCount === 1 ? "1" : "0.5" } as CSSStyleDeclaration; + }); + + const result = autofillInlineMenuContentService["getPageIsOpaque"](); + + expect(result).toBe(false); + }); + + it("returns false when opacity of at least one duplicate body is below threshold", () => { + const duplicateBody = document.createElement("body"); + jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([document.documentElement, document.body, duplicateBody] as any); + let callCount = 0; + jest.spyOn(globalThis.window, "getComputedStyle").mockImplementation(() => { + callCount++; + + let opacityValue = "0.5"; + switch (callCount) { + case 1: + opacityValue = "1"; + break; + case 2: + opacityValue = "0.7"; + break; + default: + break; + } + + return { opacity: opacityValue } as CSSStyleDeclaration; + }); + + const result = autofillInlineMenuContentService["getPageIsOpaque"](); + + expect(result).toBe(false); + }); + + it("returns true when opacity is above threshold", () => { + jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([document.documentElement, document.body] as any); + jest + .spyOn(globalThis.window, "getComputedStyle") + .mockImplementation(() => ({ opacity: "0.7" }) as CSSStyleDeclaration); + + const result = autofillInlineMenuContentService["getPageIsOpaque"](); + + expect(result).toBe(true); + }); + + it("returns false when opacity is at threshold", () => { + jest + .spyOn(globalThis.document, "querySelectorAll") + .mockReturnValue([document.documentElement, document.body] as any); + jest + .spyOn(globalThis.window, "getComputedStyle") + .mockImplementation(() => ({ opacity: "0.6" }) as CSSStyleDeclaration); + + const result = autofillInlineMenuContentService["getPageIsOpaque"](); + + expect(result).toBe(false); + }); }); }); diff --git a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts index b550ae203d5..b61e5e19d53 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/content/autofill-inline-menu-content.service.ts @@ -22,6 +22,19 @@ import { import { AutofillInlineMenuButtonIframe } from "../iframe-content/autofill-inline-menu-button-iframe"; import { AutofillInlineMenuListIframe } from "../iframe-content/autofill-inline-menu-list-iframe"; +const experienceValidationBackoffThresholds = { + topLayer: { + countLimit: 5, + timeSpanLimit: 5000, + }, + popoverAttribute: { + countLimit: 10, + timeSpanLimit: 5000, + }, +}; + +type BackoffCheckType = keyof typeof experienceValidationBackoffThresholds; + export class AutofillInlineMenuContentService implements AutofillInlineMenuContentServiceInterface { private readonly sendExtensionMessage = sendExtensionMessage; private readonly generateRandomCustomElementName = generateRandomCustomElementName; @@ -35,6 +48,19 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte private bodyMutationObserver: MutationObserver; private inlineMenuElementsMutationObserver: MutationObserver; private containerElementMutationObserver: MutationObserver; + private refreshCountWithinTimeThreshold: { [key in BackoffCheckType]: number } = { + topLayer: 0, + popoverAttribute: 0, + }; + private lastTrackedTimestamp = { + topLayer: Date.now(), + popoverAttribute: Date.now(), + }; + /** + * Distinct from preventing inline menu script injection, this is for cases + * where the page is subsequently determined to be risky. + */ + private inlineMenuEnabled = true; private mutationObserverIterations = 0; private mutationObserverIterationsResetTimeout: number | NodeJS.Timeout; private handlePersistentLastChildOverrideTimeout: number | NodeJS.Timeout; @@ -140,6 +166,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * Updates the position of both the inline menu button and inline menu list. */ private async appendInlineMenuElements({ overlayElement }: AutofillExtensionMessage) { + if (!this.inlineMenuEnabled) { + return; + } + if (overlayElement === AutofillOverlayElement.Button) { return this.appendButtonElement(); } @@ -151,6 +181,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * Updates the position of the inline menu button. */ private async appendButtonElement(): Promise { + if (!this.inlineMenuEnabled) { + return; + } + if (!this.buttonElement) { this.createButtonElement(); this.updateCustomElementDefaultStyles(this.buttonElement); @@ -167,6 +201,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * Updates the position of the inline menu list. */ private async appendListElement(): Promise { + if (!this.inlineMenuEnabled) { + return; + } + if (!this.listElement) { this.createListElement(); this.updateCustomElementDefaultStyles(this.listElement); @@ -219,6 +257,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * to create the element if it already exists in the DOM. */ private createButtonElement() { + if (!this.inlineMenuEnabled) { + return; + } + if (this.isFirefoxBrowser) { this.buttonElement = globalThis.document.createElement("div"); this.buttonElement.setAttribute("popover", "manual"); @@ -240,8 +282,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.buttonElement = globalThis.document.createElement(customElementName); this.buttonElement.setAttribute("popover", "manual"); - - this.createInternalStyleNode(this.buttonElement); } /** @@ -249,6 +289,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * to create the element if it already exists in the DOM. */ private createListElement() { + if (!this.inlineMenuEnabled) { + return; + } + if (this.isFirefoxBrowser) { this.listElement = globalThis.document.createElement("div"); this.listElement.setAttribute("popover", "manual"); @@ -270,30 +314,6 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte this.listElement = globalThis.document.createElement(customElementName); this.listElement.setAttribute("popover", "manual"); - - this.createInternalStyleNode(this.listElement); - } - - /** - * Builds and prepends an internal stylesheet to the container node with rules - * to prevent targeting by the host's global styling rules. This should only be - * used for pseudo elements such as `::backdrop` or `::before`. All other - * styles should be applied inline upon the parent container itself. - */ - private createInternalStyleNode(parent: HTMLElement) { - const css = document.createTextNode(` - ${parent.tagName}::backdrop { - background: none !important; - pointer-events: none !important; - } - ${parent.tagName}::before, ${parent.tagName}::after { - content:"" !important; - } - `); - const style = globalThis.document.createElement("style"); - style.setAttribute("type", "text/css"); - style.appendChild(css); - parent.prepend(style); } /** @@ -407,14 +427,23 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte } const element = record.target as HTMLElement; - if (record.attributeName !== "style") { - this.removeModifiedElementAttributes(element); + if (record.attributeName === "popover" && this.inlineMenuEnabled) { + const attributeValue = element.getAttribute(record.attributeName); + if (attributeValue !== "manual") { + this.refreshPopoverAttribute(element); + } continue; } - element.removeAttribute("style"); - this.updateCustomElementDefaultStyles(element); + if (record.attributeName === "style") { + element.removeAttribute("style"); + this.updateCustomElementDefaultStyles(element); + + continue; + } + + this.removeModifiedElementAttributes(element); } }; @@ -428,7 +457,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte const attributes = Array.from(element.attributes); for (let attributeIndex = 0; attributeIndex < attributes.length; attributeIndex++) { const attribute = attributes[attributeIndex]; - if (attribute.name === "style") { + if (attribute.name === "style" || attribute.name === "popover") { continue; } @@ -458,7 +487,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte private checkPageRisks = async () => { const pageIsOpaque = await this.getPageIsOpaque(); - const risksFound = !pageIsOpaque; + const risksFound = !pageIsOpaque || !this.inlineMenuEnabled; if (risksFound) { this.closeInlineMenu(); @@ -509,7 +538,49 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte return otherTopLayeritems; }; + /** + * Internally track owned injected experience refreshes as a side-effect + * of host page interference. + */ + private checkAndUpdateRefreshCount = (countType: BackoffCheckType) => { + if (!this.inlineMenuEnabled) { + return; + } + + const { countLimit, timeSpanLimit } = experienceValidationBackoffThresholds[countType]; + const now = Date.now(); + const timeSinceLastTrackedRefresh = now - this.lastTrackedTimestamp[countType]; + const currentlyWithinTimeThreshold = timeSinceLastTrackedRefresh <= timeSpanLimit; + const withinCountThreshold = this.refreshCountWithinTimeThreshold[countType] <= countLimit; + + if (currentlyWithinTimeThreshold) { + if (withinCountThreshold) { + this.refreshCountWithinTimeThreshold[countType]++; + } else { + // Set inline menu to be off; page is aggressively trying to take top position of top layer + this.inlineMenuEnabled = false; + void this.checkPageRisks(); + + const warningMessage = chrome.i18n.getMessage("topLayerHijackWarning"); + globalThis.window.alert(warningMessage); + } + } else { + this.lastTrackedTimestamp[countType] = now; + this.refreshCountWithinTimeThreshold[countType] = 0; + } + }; + + private refreshPopoverAttribute = (element: HTMLElement) => { + this.checkAndUpdateRefreshCount("popoverAttribute"); + element.setAttribute("popover", "manual"); + element.showPopover(); + }; + refreshTopLayerPosition = () => { + if (!this.inlineMenuEnabled) { + return; + } + const otherTopLayerItems = this.getUnownedTopLayerItems(); // No need to refresh if there are no other top-layer items @@ -523,6 +594,7 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte const listInDocument = this.listElement && (globalThis.document.getElementsByTagName(this.listElement.tagName)[0] as HTMLElement); + if (buttonInDocument) { buttonInDocument.hidePopover(); buttonInDocument.showPopover(); @@ -532,6 +604,10 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte listInDocument.hidePopover(); listInDocument.showPopover(); } + + if (buttonInDocument || listInDocument) { + this.checkAndUpdateRefreshCount("topLayer"); + } }; /** @@ -541,24 +617,28 @@ export class AutofillInlineMenuContentService implements AutofillInlineMenuConte * `body` (enforced elsewhere). */ private getPageIsOpaque = () => { - // These are computed style values, so we don't need to worry about non-float values - // for `opacity`, here // @TODO for definitive checks, traverse up the node tree from the inline menu container; // nodes can exist between `html` and `body` - const htmlElement = globalThis.document.querySelector("html"); - const bodyElement = globalThis.document.querySelector("body"); + /** + * `querySelectorAll` for (non-standard) cases where the page has additional copies of + * page nodes that should be unique + */ + const pageElements = globalThis.document.querySelectorAll("html, body"); - if (!htmlElement || !bodyElement) { + if (!pageElements.length) { return false; } - const htmlOpacity = globalThis.window.getComputedStyle(htmlElement)?.opacity || "0"; - const bodyOpacity = globalThis.window.getComputedStyle(bodyElement)?.opacity || "0"; + return [...pageElements].every((element) => { + // These are computed style values, so we don't need to worry about non-float values + // for `opacity`, here + const elementOpacity = globalThis.window.getComputedStyle(element)?.opacity || "0"; - // Any value above this is considered "opaque" for our purposes - const opacityThreshold = 0.6; + // Any value above this is considered "opaque" for our purposes + const opacityThreshold = 0.6; - return parseFloat(htmlOpacity) > opacityThreshold && parseFloat(bodyOpacity) > opacityThreshold; + return parseFloat(elementOpacity) > opacityThreshold; + }); }; /** diff --git a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe-element.ts b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe-element.ts index 2fea65a7f01..3e2b364b17b 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe-element.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/iframe-content/autofill-inline-menu-iframe-element.ts @@ -8,7 +8,10 @@ export class AutofillInlineMenuIframeElement { iframeTitle: string, ariaAlert?: string, ) { + const style = this.createInternalStyleNode(); const shadow: ShadowRoot = element.attachShadow({ mode: "closed" }); + shadow.prepend(style); + const autofillInlineMenuIframeService = new AutofillInlineMenuIframeService( shadow, portName, @@ -18,4 +21,50 @@ export class AutofillInlineMenuIframeElement { ); autofillInlineMenuIframeService.initMenuIframe(); } + + /** + * Builds and prepends an internal stylesheet to the container node with rules + * to prevent targeting by the host's global styling rules. This should only be + * used for pseudo elements such as `::backdrop` or `::before`. All other + * styles should be applied inline upon the parent container itself for improved + * specificity priority. + */ + private createInternalStyleNode() { + const css = document.createTextNode(` + :host::backdrop, + :host::before, + :host::after { + all: initial !important; + backdrop-filter: none !important; + filter: none !important; + inset: auto !important; + touch-action: auto !important; + user-select: text !important; + display: none !important; + position: relative !important; + top: auto !important; + right: auto !important; + bottom: auto !important; + left: auto !important; + transform: none !important; + transform-origin: 50% 50% !important; + opacity: 1 !important; + mix-blend-mode: normal !important; + isolation: isolate !important; + z-index: 0 !important; + background: none !important; + background-color: transparent !important; + background-image: none !important; + width: 0 !important; + height: 0 !important; + content: "" !important; + pointer-events: all !important; + } + `); + const style = globalThis.document.createElement("style"); + style.setAttribute("type", "text/css"); + style.appendChild(css); + + return style; + } } diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.spec.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.spec.ts index f7a5727e47f..d7a61bec61f 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.spec.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.spec.ts @@ -6,11 +6,13 @@ import { AutofillInlineMenuContainer } from "./autofill-inline-menu-container"; describe("AutofillInlineMenuContainer", () => { const portKey = "testPortKey"; - const iframeUrl = "https://example.com"; + const extensionOrigin = "chrome-extension://test-extension-id"; + const iframeUrl = `${extensionOrigin}/overlay/menu-list.html`; const pageTitle = "Example"; let autofillInlineMenuContainer: AutofillInlineMenuContainer; beforeEach(() => { + jest.spyOn(chrome.runtime, "getURL").mockReturnValue(`${extensionOrigin}/`); autofillInlineMenuContainer = new AutofillInlineMenuContainer(); }); @@ -28,7 +30,7 @@ describe("AutofillInlineMenuContainer", () => { portName: AutofillOverlayPort.List, }; - postWindowMessage(message); + postWindowMessage(message, extensionOrigin); expect(autofillInlineMenuContainer["defaultIframeAttributes"].src).toBe(message.iframeUrl); expect(autofillInlineMenuContainer["defaultIframeAttributes"].title).toBe(message.pageTitle); @@ -44,15 +46,48 @@ describe("AutofillInlineMenuContainer", () => { portName: AutofillOverlayPort.Button, }; - postWindowMessage(message); + postWindowMessage(message, extensionOrigin); jest.spyOn(autofillInlineMenuContainer["inlineMenuPageIframe"].contentWindow, "postMessage"); autofillInlineMenuContainer["inlineMenuPageIframe"].dispatchEvent(new Event("load")); expect(chrome.runtime.connect).toHaveBeenCalledWith({ name: message.portName }); + const expectedMessage = expect.objectContaining({ + ...message, + token: expect.any(String), + }); expect( autofillInlineMenuContainer["inlineMenuPageIframe"].contentWindow.postMessage, - ).toHaveBeenCalledWith(message, "*"); + ).toHaveBeenCalledWith(expectedMessage, "*"); + }); + + it("ignores initialization when URLs are not from extension origin", () => { + const invalidIframeUrlMessage = { + command: "initAutofillInlineMenuList", + iframeUrl: "https://malicious.com/overlay/menu-list.html", + pageTitle, + portKey, + portName: AutofillOverlayPort.List, + }; + + postWindowMessage(invalidIframeUrlMessage, extensionOrigin); + expect(autofillInlineMenuContainer["inlineMenuPageIframe"]).toBeUndefined(); + expect(autofillInlineMenuContainer["isInitialized"]).toBe(false); + + autofillInlineMenuContainer = new AutofillInlineMenuContainer(); + + const invalidStyleSheetUrlMessage = { + command: "initAutofillInlineMenuList", + iframeUrl, + pageTitle, + portKey, + portName: AutofillOverlayPort.List, + styleSheetUrl: "https://malicious.com/styles.css", + }; + + postWindowMessage(invalidStyleSheetUrlMessage, extensionOrigin); + expect(autofillInlineMenuContainer["inlineMenuPageIframe"]).toBeUndefined(); + expect(autofillInlineMenuContainer["isInitialized"]).toBe(false); }); }); @@ -69,7 +104,7 @@ describe("AutofillInlineMenuContainer", () => { portName: AutofillOverlayPort.Button, }; - postWindowMessage(message); + postWindowMessage(message, extensionOrigin); iframe = autofillInlineMenuContainer["inlineMenuPageIframe"]; jest.spyOn(iframe.contentWindow, "postMessage"); @@ -112,7 +147,8 @@ describe("AutofillInlineMenuContainer", () => { }); it("posts a message to the background from the inline menu iframe", () => { - const message = { command: "checkInlineMenuButtonFocused", portKey }; + const token = autofillInlineMenuContainer["token"]; + const message = { command: "checkInlineMenuButtonFocused", portKey, token }; postWindowMessage(message, "null", iframe.contentWindow as any); @@ -124,7 +160,28 @@ describe("AutofillInlineMenuContainer", () => { postWindowMessage(message); - expect(iframe.contentWindow.postMessage).toHaveBeenCalledWith(message, "*"); + const expectedMessage = expect.objectContaining({ + ...message, + token: expect.any(String), + }); + expect(iframe.contentWindow.postMessage).toHaveBeenCalledWith(expectedMessage, "*"); + }); + + it("ignores messages from iframe with invalid token", () => { + const message = { command: "checkInlineMenuButtonFocused", portKey, token: "invalid-token" }; + + postWindowMessage(message, "null", iframe.contentWindow as any); + + expect(port.postMessage).not.toHaveBeenCalled(); + }); + + it("ignores messages from iframe with commands not in the allowlist", () => { + const token = autofillInlineMenuContainer["token"]; + const message = { command: "maliciousCommand", portKey, token }; + + postWindowMessage(message, "null", iframe.contentWindow as any); + + expect(port.postMessage).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.ts index 6d85982a1ac..ad0b11f0bc6 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/menu-container/autofill-inline-menu-container.ts @@ -1,6 +1,6 @@ import { EVENTS } from "@bitwarden/common/autofill/constants"; -import { setElementStyles } from "../../../../utils"; +import { generateRandomChars, setElementStyles } from "../../../../utils"; import { InitAutofillInlineMenuElementMessage, AutofillInlineMenuContainerWindowMessageHandlers, @@ -8,14 +8,37 @@ import { AutofillInlineMenuContainerPortMessage, } from "../../abstractions/autofill-inline-menu-container"; +/** + * Allowlist of commands that can be sent to the background script. + */ +const ALLOWED_BG_COMMANDS = new Set([ + "addNewVaultItem", + "autofillInlineMenuBlurred", + "autofillInlineMenuButtonClicked", + "checkAutofillInlineMenuButtonFocused", + "checkInlineMenuButtonFocused", + "fillAutofillInlineMenuCipher", + "fillGeneratedPassword", + "redirectAutofillInlineMenuFocusOut", + "refreshGeneratedPassword", + "refreshOverlayCiphers", + "triggerDelayedAutofillInlineMenuClosure", + "updateAutofillInlineMenuColorScheme", + "updateAutofillInlineMenuListHeight", + "unlockVault", + "viewSelectedCipher", +]); + export class AutofillInlineMenuContainer { private readonly setElementStyles = setElementStyles; - private readonly extensionOriginsSet: Set; private port: chrome.runtime.Port | null = null; /** Non-null asserted. */ private portName!: string; /** Non-null asserted. */ private inlineMenuPageIframe!: HTMLIFrameElement; + private token: string; + private isInitialized: boolean = false; + private readonly extensionOrigin: string; private readonly iframeStyles: Partial = { all: "initial", position: "fixed", @@ -49,11 +72,8 @@ export class AutofillInlineMenuContainer { }; constructor() { - this.extensionOriginsSet = new Set([ - chrome.runtime.getURL("").slice(0, -1).toLowerCase(), // Remove the trailing slash and normalize the extension url to lowercase - "null", - ]); - + this.token = generateRandomChars(32); + this.extensionOrigin = chrome.runtime.getURL("").slice(0, -1); globalThis.addEventListener("message", this.handleWindowMessage); } @@ -63,9 +83,22 @@ export class AutofillInlineMenuContainer { * @param message - The message containing the iframe url and page title. */ private handleInitInlineMenuIframe(message: InitAutofillInlineMenuElementMessage) { + if (this.isInitialized) { + return; + } + + if (!this.isExtensionUrl(message.iframeUrl)) { + return; + } + + if (message.styleSheetUrl && !this.isExtensionUrl(message.styleSheetUrl)) { + return; + } + this.defaultIframeAttributes.src = message.iframeUrl; this.defaultIframeAttributes.title = message.pageTitle; this.portName = message.portName; + this.isInitialized = true; this.inlineMenuPageIframe = globalThis.document.createElement("iframe"); this.setElementStyles(this.inlineMenuPageIframe, this.iframeStyles, true); @@ -81,6 +114,26 @@ export class AutofillInlineMenuContainer { globalThis.document.body.appendChild(this.inlineMenuPageIframe); } + /** + * validates that a URL is from the extension origin. + * prevents loading arbitrary URLs in the iframe. + * + * @param url - The URL to validate. + */ + private isExtensionUrl(url: string): boolean { + if (!url) { + return false; + } + try { + const urlObj = new URL(url); + return ( + urlObj.origin === this.extensionOrigin || urlObj.href.startsWith(this.extensionOrigin + "/") + ); + } catch { + return false; + } + } + /** * Sets up the port message listener for the inline menu page. * @@ -88,7 +141,8 @@ export class AutofillInlineMenuContainer { */ private setupPortMessageListener = (message: InitAutofillInlineMenuElementMessage) => { this.port = chrome.runtime.connect({ name: this.portName }); - this.postMessageToInlineMenuPage(message); + const initMessage = { ...message, token: this.token }; + this.postMessageToInlineMenuPageUnsafe(initMessage); }; /** @@ -97,6 +151,22 @@ export class AutofillInlineMenuContainer { * @param message - The message to post. */ private postMessageToInlineMenuPage(message: AutofillInlineMenuContainerWindowMessage) { + if (this.inlineMenuPageIframe?.contentWindow) { + const messageWithToken = { ...message, token: this.token }; + this.postMessageToInlineMenuPageUnsafe(messageWithToken); + } + } + + /** + * Posts a message to the inline menu page iframe without token validation. + * + * UNSAFE: Bypasses token authentication and sends raw messages. Only use internally + * when sending trusted messages (e.g., initialization) or when token validation + * would create circular dependencies. External callers should use postMessageToInlineMenuPage(). + * + * @param message - The message to post. + */ + private postMessageToInlineMenuPageUnsafe(message: Record) { if (this.inlineMenuPageIframe?.contentWindow) { this.inlineMenuPageIframe.contentWindow.postMessage(message, "*"); } @@ -108,9 +178,15 @@ export class AutofillInlineMenuContainer { * @param message - The message to post. */ private postMessageToBackground(message: AutofillInlineMenuContainerPortMessage) { - if (this.port) { - this.port.postMessage(message); + if (!this.port) { + return; } + + if (message.command && !ALLOWED_BG_COMMANDS.has(message.command)) { + return; + } + + this.port.postMessage(message); } /** @@ -124,23 +200,33 @@ export class AutofillInlineMenuContainer { return; } - if ( - this.windowMessageHandlers[ - message.command as keyof AutofillInlineMenuContainerWindowMessageHandlers - ] - ) { - this.windowMessageHandlers[ - message.command as keyof AutofillInlineMenuContainerWindowMessageHandlers - ](message); + if (this.windowMessageHandlers[message.command]) { + // only accept init messages from extension origin or parent window + if ( + (message.command === "initAutofillInlineMenuButton" || + message.command === "initAutofillInlineMenuList") && + !this.isMessageFromExtensionOrigin(event) && + !this.isMessageFromParentWindow(event) + ) { + return; + } + this.windowMessageHandlers[message.command](message); return; } if (this.isMessageFromParentWindow(event)) { + // messages from parent window are trusted and forwarded to iframe this.postMessageToInlineMenuPage(message); return; } - this.postMessageToBackground(message); + // messages from iframe to background require object identity verification with a contentWindow check and token auth + if (this.isMessageFromInlineMenuPageIframe(event)) { + if (this.isValidSessionToken(message)) { + this.postMessageToBackground(message); + } + return; + } }; /** @@ -184,10 +270,34 @@ export class AutofillInlineMenuContainer { if (!this.inlineMenuPageIframe) { return false; } + // only trust the specific iframe we created + return this.inlineMenuPageIframe.contentWindow === event.source; + } - return ( - this.inlineMenuPageIframe.contentWindow === event.source && - this.extensionOriginsSet.has(event.origin.toLowerCase()) - ); + /** + * Validates that the message contains a valid session token. + * The session token is generated when the container is created and is refreshed + * every time the inline menu container is recreated. + * + */ + private isValidSessionToken(message: { token?: string }): boolean { + return message.token === this.token; + } + + /** + * Validates that a message event originates from the extension. + * + * @param event - The message event to validate. + * @returns True if the message is from the extension origin. + */ + private isMessageFromExtensionOrigin(event: MessageEvent): boolean { + try { + if (event.origin === "null") { + return false; + } + return event.origin === this.extensionOrigin; + } catch { + return false; + } } } diff --git a/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts b/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts index 89f44a6a80d..ea77e3e434d 100644 --- a/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts +++ b/apps/browser/src/autofill/overlay/inline-menu/pages/shared/autofill-inline-menu-page-element.ts @@ -16,6 +16,7 @@ export class AutofillInlineMenuPageElement extends HTMLElement { private portKey!: string; /** Non-null asserted. */ protected windowMessageHandlers!: AutofillInlineMenuPageElementWindowMessageHandlers; + private token?: string; constructor() { super(); @@ -37,8 +38,12 @@ export class AutofillInlineMenuPageElement extends HTMLElement { styleSheetUrl: string, translations: Record, portKey: string, + token?: string, ): Promise { this.portKey = portKey; + if (token) { + this.token = token; + } this.translations = translations; globalThis.document.documentElement.setAttribute("lang", this.getTranslation("locale")); @@ -58,7 +63,11 @@ export class AutofillInlineMenuPageElement extends HTMLElement { * @param message - The message to post */ protected postMessageToParent(message: AutofillInlineMenuPageElementWindowMessage) { - globalThis.parent.postMessage({ portKey: this.portKey, ...message }, "*"); + const messageWithAuth: Record = { portKey: this.portKey, ...message }; + if (this.token) { + messageWithAuth.token = this.token; + } + globalThis.parent.postMessage(messageWithAuth, "*"); } /** @@ -105,6 +114,15 @@ export class AutofillInlineMenuPageElement extends HTMLElement { } const message = event?.data; + + if ( + message?.token && + (message?.command === "initAutofillInlineMenuButton" || + message?.command === "initAutofillInlineMenuList") + ) { + this.token = message.token; + } + const handler = this.windowMessageHandlers[message?.command]; if (!handler) { return; diff --git a/apps/browser/src/autofill/services/abstractions/autofill.service.ts b/apps/browser/src/autofill/services/abstractions/autofill.service.ts index 85bf8c16610..05bfbf378a8 100644 --- a/apps/browser/src/autofill/services/abstractions/autofill.service.ts +++ b/apps/browser/src/autofill/services/abstractions/autofill.service.ts @@ -6,6 +6,7 @@ import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { AutofillMessageCommand } from "../../enums/autofill-message.enums"; +import { InlineMenuFillType } from "../../enums/autofill-overlay.enum"; import AutofillField from "../../models/autofill-field"; import AutofillForm from "../../models/autofill-form"; import AutofillPageDetails from "../../models/autofill-page-details"; @@ -30,6 +31,7 @@ export interface AutoFillOptions { autoSubmitLogin?: boolean; focusedFieldForm?: string; focusedFieldOpid?: string; + inlineMenuFillType?: InlineMenuFillType; } export interface FormData { @@ -49,6 +51,7 @@ export interface GenerateFillScriptOptions { tabUrl: string; defaultUriMatch: UriMatchStrategySetting; focusedFieldOpid?: string; + inlineMenuFillType?: InlineMenuFillType; } export type CollectPageDetailsResponseMessage = { diff --git a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts index 7854dc8e161..817a7cca43c 100644 --- a/apps/browser/src/autofill/services/autofill-overlay-content.service.ts +++ b/apps/browser/src/autofill/services/autofill-overlay-content.service.ts @@ -1118,6 +1118,12 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ * @param autofillFieldData - Autofill field data captured from the form field element. */ private async setQualifiedLoginFillType(autofillFieldData: AutofillField) { + // Check if this is a current password field in a password change form + if (this.inlineMenuFieldQualificationService.isUpdateCurrentPasswordField(autofillFieldData)) { + autofillFieldData.inlineMenuFillType = InlineMenuFillTypes.CurrentPasswordUpdate; + return; + } + autofillFieldData.inlineMenuFillType = CipherType.Login; autofillFieldData.showPasskeys = autofillFieldData.autoCompleteType.includes("webauthn"); diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index b436214f327..13e97766594 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -44,6 +44,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service"; import { BrowserApi } from "../../platform/browser/browser-api"; import { BrowserScriptInjectorService } from "../../platform/services/browser-script-injector.service"; import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums"; +import { InlineMenuFillTypes } from "../enums/autofill-overlay.enum"; import { AutofillPort } from "../enums/autofill-port.enum"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; @@ -103,6 +104,15 @@ describe("AutofillService", () => { beforeEach(() => { configService = mock(); configService.getFeatureFlag$.mockImplementation(() => of(false)); + + // Initialize domainSettingsService BEFORE it's used + domainSettingsService = new DefaultDomainSettingsService( + fakeStateProvider, + policyService, + accountService, + ); + domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); + scriptInjectorService = new BrowserScriptInjectorService( domainSettingsService, platformUtilsService, @@ -141,12 +151,6 @@ describe("AutofillService", () => { userNotificationsSettings, messageListener, ); - domainSettingsService = new DefaultDomainSettingsService( - fakeStateProvider, - policyService, - accountService, - ); - domainSettingsService.equivalentDomains$ = of(mockEquivalentDomains); jest.spyOn(BrowserApi, "tabSendMessage"); }); @@ -2077,6 +2081,193 @@ describe("AutofillService", () => { }); }); + describe("given password generation with inlineMenuFillType", () => { + beforeEach(() => { + pageDetails.forms = undefined; + pageDetails.fields = []; // Clear fields to start fresh + options.inlineMenuFillType = InlineMenuFillTypes.PasswordGeneration; + options.cipher.login.totp = null; // Disable TOTP for these tests + }); + + it("includes all password fields from the same form when filling with password generation", async () => { + const newPasswordField = createAutofillFieldMock({ + opid: "new-password", + type: "password", + form: "validFormId", + elementNumber: 2, + }); + const confirmPasswordField = createAutofillFieldMock({ + opid: "confirm-password", + type: "password", + form: "validFormId", + elementNumber: 3, + }); + pageDetails.fields.push(newPasswordField, confirmPasswordField); + options.focusedFieldOpid = newPasswordField.opid; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(filledFields[newPasswordField.opid]).toBeDefined(); + expect(filledFields[confirmPasswordField.opid]).toBeDefined(); + }); + + it("finds username field for the first password field when generating passwords", async () => { + const newPasswordField = createAutofillFieldMock({ + opid: "new-password", + type: "password", + form: "validFormId", + elementNumber: 2, + }); + pageDetails.fields.push(newPasswordField); + options.focusedFieldOpid = newPasswordField.opid; + jest.spyOn(autofillService as any, "findUsernameField"); + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(autofillService["findUsernameField"]).toHaveBeenCalledWith( + pageDetails, + expect.objectContaining({ opid: newPasswordField.opid }), + false, + false, + true, + ); + }); + + it("does not include password fields from different forms", async () => { + const formAPasswordField = createAutofillFieldMock({ + opid: "form-a-password", + type: "password", + form: "formA", + elementNumber: 1, + }); + const formBPasswordField = createAutofillFieldMock({ + opid: "form-b-password", + type: "password", + form: "formB", + elementNumber: 2, + }); + pageDetails.fields = [formAPasswordField, formBPasswordField]; + options.focusedFieldOpid = formAPasswordField.opid; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(filledFields[formAPasswordField.opid]).toBeDefined(); + expect(filledFields[formBPasswordField.opid]).toBeUndefined(); + }); + }); + + describe("given current password update with inlineMenuFillType", () => { + beforeEach(() => { + pageDetails.forms = undefined; + pageDetails.fields = []; // Clear fields to start fresh + options.inlineMenuFillType = InlineMenuFillTypes.CurrentPasswordUpdate; + options.cipher.login.totp = null; // Disable TOTP for these tests + }); + + it("includes all password fields from the same form when updating current password", async () => { + const currentPasswordField = createAutofillFieldMock({ + opid: "current-password", + type: "password", + form: "validFormId", + elementNumber: 1, + }); + const newPasswordField = createAutofillFieldMock({ + opid: "new-password", + type: "password", + form: "validFormId", + elementNumber: 2, + }); + const confirmPasswordField = createAutofillFieldMock({ + opid: "confirm-password", + type: "password", + form: "validFormId", + elementNumber: 3, + }); + pageDetails.fields.push(currentPasswordField, newPasswordField, confirmPasswordField); + options.focusedFieldOpid = currentPasswordField.opid; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(filledFields[currentPasswordField.opid]).toBeDefined(); + expect(filledFields[newPasswordField.opid]).toBeDefined(); + expect(filledFields[confirmPasswordField.opid]).toBeDefined(); + }); + + it("includes all password fields from the same form without TOTP", async () => { + const currentPasswordField = createAutofillFieldMock({ + opid: "current-password", + type: "password", + form: "validFormId", + elementNumber: 1, + }); + const newPasswordField = createAutofillFieldMock({ + opid: "new-password", + type: "password", + form: "validFormId", + elementNumber: 2, + }); + pageDetails.fields.push(currentPasswordField, newPasswordField); + options.focusedFieldOpid = currentPasswordField.opid; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(filledFields[currentPasswordField.opid]).toBeDefined(); + expect(filledFields[newPasswordField.opid]).toBeDefined(); + }); + + it("does not include password fields from different forms during password update", async () => { + const formAPasswordField = createAutofillFieldMock({ + opid: "form-a-password", + type: "password", + form: "formA", + elementNumber: 1, + }); + const formBPasswordField = createAutofillFieldMock({ + opid: "form-b-password", + type: "password", + form: "formB", + elementNumber: 2, + }); + pageDetails.fields = [formAPasswordField, formBPasswordField]; + options.focusedFieldOpid = formAPasswordField.opid; + + await autofillService["generateLoginFillScript"]( + fillScript, + pageDetails, + filledFields, + options, + ); + + expect(filledFields[formAPasswordField.opid]).toBeDefined(); + expect(filledFields[formBPasswordField.opid]).toBeUndefined(); + }); + }); + describe("given a set of page details that does not contain a password field", () => { let emailField: AutofillField; let emailFieldView: FieldView; @@ -3140,12 +3331,16 @@ describe("AutofillService", () => { "example.com", "exampleapp.com", ]); - domainSettingsService.equivalentDomains$ = of([["not-example.com"]]); const pageUrl = "https://subdomain.example.com"; const tabUrl = "https://www.not-example.com"; const generateFillScriptOptions = createGenerateFillScriptOptionsMock({ tabUrl }); generateFillScriptOptions.cipher.login.matchesUri = jest.fn().mockReturnValueOnce(false); + // Mock getUrlEquivalentDomains to return the expected domains + jest + .spyOn(domainSettingsService, "getUrlEquivalentDomains") + .mockReturnValue(of(equivalentDomains)); + const result = await autofillService["inUntrustedIframe"](pageUrl, generateFillScriptOptions); expect(generateFillScriptOptions.cipher.login.matchesUri).toHaveBeenCalledWith( diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index fcc8861228b..010f5ea0f27 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -52,6 +52,7 @@ import { ScriptInjectorService } from "../../platform/services/abstractions/scri // eslint-disable-next-line no-restricted-imports import { openVaultItemPasswordRepromptPopout } from "../../vault/popup/utils/vault-popout-window"; import { AutofillMessageCommand, AutofillMessageSender } from "../enums/autofill-message.enums"; +import { InlineMenuFillTypes } from "../enums/autofill-overlay.enum"; import { AutofillPort } from "../enums/autofill-port.enum"; import AutofillField from "../models/autofill-field"; import AutofillPageDetails from "../models/autofill-page-details"; @@ -452,6 +453,7 @@ export default class AutofillService implements AutofillServiceInterface { tabUrl: tab.url, defaultUriMatch: defaultUriMatch, focusedFieldOpid: options.focusedFieldOpid, + inlineMenuFillType: options.inlineMenuFillType, }); if (!fillScript || !fillScript.script || !fillScript.script.length) { @@ -971,26 +973,53 @@ export default class AutofillService implements AutofillServiceInterface { if (passwordFields.length && !passwords.length) { // in the event that password fields exist but weren't processed within form elements. - // select matching password if focused, otherwise first in prioritized list. for username, use focused field if it matches, otherwise find field before password. - const passwordFieldToUse = focusedField - ? prioritizedPasswordFields.find(passwordMatchesFocused) || prioritizedPasswordFields[0] - : prioritizedPasswordFields[0]; + const isPasswordGeneration = + options.inlineMenuFillType === InlineMenuFillTypes.PasswordGeneration; + const isCurrentPasswordUpdate = + options.inlineMenuFillType === InlineMenuFillTypes.CurrentPasswordUpdate; - if (passwordFieldToUse) { - passwords.push(passwordFieldToUse); + // For password generation or current password update, include all password fields from the same form + // This ensures we have access to all fields regardless of their login/registration classification + if ((isPasswordGeneration || isCurrentPasswordUpdate) && focusedField) { + // Add all password fields from the same form as the focused field + const focusedFieldForm = focusedField.form; - if (login.username && passwordFieldToUse.elementNumber > 0) { - username = getUsernameForPassword(passwordFieldToUse, true); + // Check both login and registration fields to ensure we get all password fields + const allPasswordFields = [...loginPasswordFields, ...registrationPasswordFields]; + allPasswordFields.forEach((passField) => { + if (passField.form === focusedFieldForm) { + passwords.push(passField); + } + }); + } + + // If we didn't add any passwords above (either not password generation/update or no matching fields), + // select matching password if focused, otherwise first in prioritized list. + if (!passwords.length) { + const passwordFieldToUse = focusedField + ? prioritizedPasswordFields.find(passwordMatchesFocused) || prioritizedPasswordFields[0] + : prioritizedPasswordFields[0]; + + if (passwordFieldToUse) { + passwords.push(passwordFieldToUse); + } + } + + // Handle username and TOTP for the first password field + const firstPasswordField = passwords[0]; + if (firstPasswordField) { + if (login.username && firstPasswordField.elementNumber > 0) { + username = getUsernameForPassword(firstPasswordField, true); if (username) { usernames.set(username.opid, username); } } - if (options.allowTotpAutofill && login.totp && passwordFieldToUse.elementNumber > 0) { + if (options.allowTotpAutofill && login.totp && firstPasswordField.elementNumber > 0) { totp = - isFocusedTotpField && passwordMatchesFocused(passwordFieldToUse) + isFocusedTotpField && passwordMatchesFocused(firstPasswordField) ? focusedField - : this.findTotpField(pageDetails, passwordFieldToUse, false, false, true); + : this.findTotpField(pageDetails, firstPasswordField, false, false, true); if (totp) { totps.push(totp); } diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts index cb76a1cc354..6e1bf07c647 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-data.service.ts @@ -58,6 +58,7 @@ export class PhishingDataService { new Set( (state?.domains?.filter((line) => line.trim().length > 0) ?? []).concat( this._testDomains, + "phishing.testcategory.com", // Included for QA to test in prod ), ), ), diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index d44a3d2a2e7..3d8f648daca 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.11.0", + "version": "2025.11.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index b6381201c7d..2b2aa0f117b 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "Bitwarden", - "version": "2025.11.0", + "version": "2025.11.1", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", @@ -164,7 +164,8 @@ "overlay/menu.html", "popup/fonts/*" ], - "matches": [""] + "matches": [""], + "use_dynamic_url": true } ], "__firefox__browser_specific_settings": { diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.html b/apps/browser/src/tools/popup/send-v2/send-v2.component.html index 0bcbd47a145..47ecd7564dc 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.html +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.html @@ -47,8 +47,8 @@ @if (showSkeletonsLoaders$ | async) { - + - + } diff --git a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts index 43a1119deca..e3baba53c42 100644 --- a/apps/browser/src/tools/popup/send-v2/send-v2.component.ts +++ b/apps/browser/src/tools/popup/send-v2/send-v2.component.ts @@ -15,6 +15,8 @@ import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { skeletonLoadingDelay } from "@bitwarden/common/vault/utils/skeleton-loading.operator"; import { ButtonModule, CalloutModule, @@ -95,8 +97,16 @@ export class SendV2Component implements OnDestroy { /** Skeleton Loading State */ protected showSkeletonsLoaders$ = combineLatest([ this.sendsLoading$, + this.searchService.isSendSearching$, this.skeletonFeatureFlag$, - ]).pipe(map(([loading, skeletonsEnabled]) => loading && skeletonsEnabled)); + ]).pipe( + map( + ([loading, cipherSearching, skeletonsEnabled]) => + (loading || cipherSearching) && skeletonsEnabled, + ), + distinctUntilChanged(), + skeletonLoadingDelay(), + ); protected title: string = "allSends"; protected noItemIcon = NoSendsIcon; @@ -110,6 +120,7 @@ export class SendV2Component implements OnDestroy { private policyService: PolicyService, private accountService: AccountService, private configService: ConfigService, + private searchService: SearchService, ) { combineLatest([ this.sendItemsService.emptyList$, diff --git a/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.html b/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.html new file mode 100644 index 00000000000..6dbc7430638 --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.html @@ -0,0 +1 @@ + diff --git a/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.ts b/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.ts new file mode 100644 index 00000000000..a30a447833b --- /dev/null +++ b/apps/browser/src/vault/popup/components/vault-fade-in-out/vault-fade-in-out.component.ts @@ -0,0 +1,20 @@ +import { animate, style, transition, trigger } from "@angular/animations"; +import { ChangeDetectionStrategy, Component, HostBinding } from "@angular/core"; + +@Component({ + selector: "vault-fade-in-out", + templateUrl: "./vault-fade-in-out.component.html", + animations: [ + trigger("fadeInOut", [ + transition(":enter", [ + style({ opacity: 0 }), + animate("100ms ease-in", style({ opacity: 1 })), + ]), + transition(":leave", [animate("300ms ease-out", style({ opacity: 0 }))]), + ]), + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VaultFadeInOutComponent { + @HostBinding("@fadeInOut") fadeInOut = true; +} diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html index faaa6a40e98..7a5a99c8100 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.html @@ -8,20 +8,32 @@ -
- - {{ "yourVaultIsEmpty" | i18n }} - -

{{ "emptyVaultDescription" | i18n }}

-
- - {{ "newLogin" | i18n }} - -
-
+ +
+ + {{ "yourVaultIsEmpty" | i18n }} + +

+ {{ "emptyVaultDescription" | i18n }} +

+
+ + {{ "newLogin" | i18n }} + +
+
+
+ + @if (skeletonFeatureFlag$ | async) { + + + + } @else { + + } - - - - - + + + + + + + + + @if (skeletonFeatureFlag$ | async) { + + + + } @else { + + } @if (showSkeletonsLoaders$ | async) { diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts index 563ec5f9709..5563cd3033b 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -23,6 +23,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; import { TaskService } from "@bitwarden/common/vault/tasks"; import { DialogService } from "@bitwarden/components"; @@ -259,6 +260,10 @@ describe("VaultV2Component", () => { getFeatureFlag$: (_: string) => of(false), }, }, + { + provide: SearchService, + useValue: { isCipherSearching$: of(false) }, + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 499e9b76757..471e6e70601 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -32,8 +32,10 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { UnionOfValues } from "@bitwarden/common/vault/types/union-of-values"; +import { skeletonLoadingDelay } from "@bitwarden/common/vault/utils/skeleton-loading.operator"; import { ButtonModule, DialogService, @@ -54,6 +56,7 @@ import { VaultPopupListFiltersService } from "../../services/vault-popup-list-fi import { VaultPopupLoadingService } from "../../services/vault-popup-loading.service"; import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service"; import { AtRiskPasswordCalloutComponent } from "../at-risk-callout/at-risk-password-callout.component"; +import { VaultFadeInOutComponent } from "../vault-fade-in-out/vault-fade-in-out.component"; import { VaultFadeInOutSkeletonComponent } from "../vault-fade-in-out-skeleton/vault-fade-in-out-skeleton.component"; import { VaultLoadingSkeletonComponent } from "../vault-loading-skeleton/vault-loading-skeleton.component"; @@ -100,6 +103,7 @@ type VaultState = UnionOfValues; TypographyModule, VaultLoadingSkeletonComponent, VaultFadeInOutSkeletonComponent, + VaultFadeInOutComponent, ], }) export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { @@ -129,7 +133,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { }), ); - private skeletonFeatureFlag$ = this.configService.getFeatureFlag$( + protected skeletonFeatureFlag$ = this.configService.getFeatureFlag$( FeatureFlag.VaultLoadingSkeletons, ); @@ -183,9 +187,18 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { map(([loading, skeletonsEnabled]) => loading && !skeletonsEnabled), ); - /** When true, show skeleton loading state */ - protected showSkeletonsLoaders$ = combineLatest([this.loading$, this.skeletonFeatureFlag$]).pipe( - map(([loading, skeletonsEnabled]) => loading && skeletonsEnabled), + /** When true, show skeleton loading state with debouncing to prevent flicker */ + protected showSkeletonsLoaders$ = combineLatest([ + this.loading$, + this.searchService.isCipherSearching$, + this.skeletonFeatureFlag$, + ]).pipe( + map( + ([loading, cipherSearching, skeletonsEnabled]) => + (loading || cipherSearching) && skeletonsEnabled, + ), + distinctUntilChanged(), + skeletonLoadingDelay(), ); protected newItemItemValues$: Observable = @@ -228,6 +241,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { private liveAnnouncer: LiveAnnouncer, private i18nService: I18nService, private configService: ConfigService, + private searchService: SearchService, ) { combineLatest([ this.vaultPopupItemsService.emptyVault$, diff --git a/apps/cli/package.json b/apps/cli/package.json index 26e1183004a..fc38440b70f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -75,7 +75,7 @@ "inquirer": "8.2.6", "jsdom": "26.1.0", "jszip": "3.10.1", - "koa": "2.16.1", + "koa": "2.16.2", "koa-bodyparser": "4.4.1", "koa-json": "2.0.2", "lowdb": "1.0.0", @@ -87,7 +87,7 @@ "papaparse": "5.5.3", "proper-lockfile": "4.1.2", "rxjs": "7.8.1", - "semver": "7.7.2", + "semver": "7.7.3", "tldts": "7.0.1", "zxcvbn": "4.4.2" } diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 10d64d59b1c..b71c2e33360 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -317,9 +317,9 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", @@ -555,10 +555,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.4" +version = "1.2.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf" +checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -638,9 +639,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.40" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" dependencies = [ "clap_builder", "clap_derive", @@ -648,9 +649,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.40" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" dependencies = [ "anstream", "anstyle", @@ -660,9 +661,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.40" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", @@ -685,17 +686,6 @@ dependencies = [ "error-code", ] -[[package]] -name = "codespan-reporting" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" -dependencies = [ - "serde", - "termcolor", - "unicode-width", -] - [[package]] name = "colorchoice" version = "1.0.3" @@ -842,65 +832,6 @@ dependencies = [ "syn", ] -[[package]] -name = "cxx" -version = "1.0.158" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a71ea7f29c73f7ffa64c50b83c9fe4d3a6d4be89a86b009eb80d5a6d3429d741" -dependencies = [ - "cc", - "cxxbridge-cmd", - "cxxbridge-flags", - "cxxbridge-macro", - "foldhash", - "link-cplusplus", -] - -[[package]] -name = "cxx-build" -version = "1.0.158" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a8232661d66dcf713394726157d3cfe0a89bfc85f52d6e9f9bbc2306797fe7" -dependencies = [ - "cc", - "codespan-reporting", - "proc-macro2", - "quote", - "scratch", - "syn", -] - -[[package]] -name = "cxxbridge-cmd" -version = "1.0.158" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f44296c8693e9ea226a48f6a122727f77aa9e9e338380cb021accaeeb7ee279" -dependencies = [ - "clap", - "codespan-reporting", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "cxxbridge-flags" -version = "1.0.158" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42f69c181c176981ae44ba9876e2ea41ce8e574c296b38d06925ce9214fb8e4" - -[[package]] -name = "cxxbridge-macro" -version = "1.0.158" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8faff5d4467e0709448187df29ccbf3b0982cc426ee444a193f87b11afb565a8" -dependencies = [ - "proc-macro2", - "quote", - "rustversion", - "syn", -] - [[package]] name = "der" version = "0.7.10" @@ -922,27 +853,21 @@ dependencies = [ "ashpd", "base64", "bitwarden-russh", - "byteorder", "bytes", "cbc", "chacha20poly1305", "core-foundation", "desktop_objc", "dirs", - "ed25519", "futures", "homedir", "interprocess", - "keytar", "libc", "linux-keyutils", "memsec", "oo7", "pin-project", - "pkcs8", "rand 0.9.1", - "rsa", - "russh-cryptovec", "scopeguard", "secmem-proc", "security-framework", @@ -950,12 +875,10 @@ dependencies = [ "serde", "serde_json", "sha2", - "ssh-encoding", "ssh-key", "sysinfo", "thiserror 2.0.12", "tokio", - "tokio-stream", "tokio-util", "tracing", "typenum", @@ -973,18 +896,14 @@ version = "0.0.0" dependencies = [ "anyhow", "autotype", - "base64", "chromium_importer", "desktop_core", - "hex", "napi", "napi-build", "napi-derive", "serde", "serde_json", "tokio", - "tokio-stream", - "tokio-util", "tracing", "tracing-subscriber", "windows-registry", @@ -997,9 +916,7 @@ version = "0.0.0" dependencies = [ "anyhow", "cc", - "core-foundation", "glob", - "thiserror 2.0.12", "tokio", "tracing", ] @@ -1008,7 +925,6 @@ dependencies = [ name = "desktop_proxy" version = "0.0.0" dependencies = [ - "anyhow", "desktop_core", "embed_plist", "futures", @@ -1289,6 +1205,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + [[package]] name = "fixedbitset" version = "0.4.2" @@ -1741,27 +1663,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" -[[package]] -name = "keytar" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d361c55fba09829ac620b040f5425bf239b1030c3d6820a84acac8da867dca4d" -dependencies = [ - "keytar-sys", -] - -[[package]] -name = "keytar-sys" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe908c6896705a1cb516cd6a5d956c63f08d95ace81b93253a98cd93e1e6a65a" -dependencies = [ - "cc", - "cxx", - "cxx-build", - "pkg-config", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1773,9 +1674,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.172" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libloading" @@ -1814,15 +1715,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "link-cplusplus" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a6f6da007f968f9def0d65a05b187e2960183de70c160204ecfccf0ee330212" -dependencies = [ - "cc", -] - [[package]] name = "linux-keyutils" version = "0.2.4" @@ -1876,7 +1768,6 @@ dependencies = [ "serde", "serde_json", "tokio", - "tokio-util", "tracing", "tracing-oslog", "tracing-subscriber", @@ -2522,21 +2413,6 @@ dependencies = [ "spki", ] -[[package]] -name = "pkcs5" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" -dependencies = [ - "aes", - "cbc", - "der", - "pbkdf2", - "scrypt", - "sha2", - "spki", -] - [[package]] name = "pkcs8" version = "0.10.2" @@ -2544,8 +2420,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ "der", - "pkcs5", - "rand_core 0.6.4", "spki", ] @@ -2924,27 +2798,12 @@ dependencies = [ "rustix 1.0.7", ] -[[package]] -name = "rustversion" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" - [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" -[[package]] -name = "salsa20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" -dependencies = [ - "cipher", -] - [[package]] name = "scc" version = "2.4.0" @@ -2960,12 +2819,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scratch" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f6280af86e5f559536da57a45ebc84948833b3bee313a7dd25232e09c878a52" - [[package]] name = "scroll" version = "0.12.0" @@ -2986,17 +2839,6 @@ dependencies = [ "syn", ] -[[package]] -name = "scrypt" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" -dependencies = [ - "pbkdf2", - "salsa20", - "sha2", -] - [[package]] name = "sdd" version = "3.0.10" @@ -3371,15 +3213,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "termtree" version = "0.5.1" @@ -3484,17 +3317,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-stream" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-util" version = "0.7.13" @@ -3694,12 +3516,6 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" - [[package]] name = "uniffi" version = "0.28.3" @@ -4030,15 +3846,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" -dependencies = [ - "windows-sys 0.59.0", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index ccf7c1f3796..864b743962d 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -39,8 +39,7 @@ futures = "=0.3.31" hex = "=0.4.3" homedir = "=0.3.4" interprocess = "=2.2.1" -keytar = "=0.1.6" -libc = "=0.2.172" +libc = "=0.2.177" linux-keyutils = "=0.2.4" memsec = "=0.7.0" napi = "=2.16.17" @@ -64,7 +63,6 @@ ssh-key = { version = "=0.6.7", default-features = false } sysinfo = "=0.35.0" thiserror = "=2.0.12" tokio = "=1.45.0" -tokio-stream = "=0.1.15" tokio-util = "=0.7.13" tracing = "=0.1.41" tracing-subscriber = { version = "=0.3.20", features = [ diff --git a/apps/desktop/desktop_native/autotype/src/windows/type_input.rs b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs index b757cf7752f..10f30f5ee4f 100644 --- a/apps/desktop/desktop_native/autotype/src/windows/type_input.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/type_input.rs @@ -33,7 +33,8 @@ impl InputOperations for Win32InputOperations { /// Attempts to type the input text wherever the user's cursor is. /// /// `input` must be a vector of utf-16 encoded characters to insert. -/// `keyboard_shortcut` must be a vector of Strings, where valid shortcut keys: Control, Alt, Super, Shift, letters a - Z +/// `keyboard_shortcut` must be a vector of Strings, where valid shortcut keys: Control, Alt, Super, +/// Shift, letters a - Z /// /// https://learn.microsoft.com/en-in/windows/win32/api/winuser/nf-winuser-sendinput pub(super) fn type_input(input: Vec, keyboard_shortcut: Vec) -> Result<()> { @@ -234,16 +235,16 @@ where #[cfg(test)] mod tests { - //! For the mocking of the traits that are static methods, we need to use the `serial_test` crate - //! in order to mock those, since the mock expectations set have to be global in absence of a `self`. - //! More info: https://docs.rs/mockall/latest/mockall/#static-methods + //! For the mocking of the traits that are static methods, we need to use the `serial_test` + //! crate in order to mock those, since the mock expectations set have to be global in + //! absence of a `self`. More info: https://docs.rs/mockall/latest/mockall/#static-methods - use super::*; - - use crate::windowing::MockErrorOperations; use serial_test::serial; use windows::Win32::Foundation::WIN32_ERROR; + use super::*; + use crate::windowing::MockErrorOperations; + #[test] fn get_alphabetic_hot_key_succeeds() { for c in ('a'..='z').chain('A'..='Z') { diff --git a/apps/desktop/desktop_native/autotype/src/windows/window_title.rs b/apps/desktop/desktop_native/autotype/src/windows/window_title.rs index 58f06eb54c1..d56a811ab5c 100644 --- a/apps/desktop/desktop_native/autotype/src/windows/window_title.rs +++ b/apps/desktop/desktop_native/autotype/src/windows/window_title.rs @@ -127,8 +127,8 @@ where /// /// # Errors /// -/// - If the actual window title length (what the win32 API declares was written into the -/// buffer), is length zero and GetLastError() != 0 , return the GetLastError() message. +/// - If the actual window title length (what the win32 API declares was written into the buffer), +/// is length zero and GetLastError() != 0 , return the GetLastError() message. fn get_window_title(window_handle: &H, expected_title_length: usize) -> Result where H: WindowHandleOperations, @@ -169,17 +169,17 @@ where #[cfg(test)] mod tests { - //! For the mocking of the traits that are static methods, we need to use the `serial_test` crate - //! in order to mock those, since the mock expectations set have to be global in absence of a `self`. - //! More info: https://docs.rs/mockall/latest/mockall/#static-methods + //! For the mocking of the traits that are static methods, we need to use the `serial_test` + //! crate in order to mock those, since the mock expectations set have to be global in + //! absence of a `self`. More info: https://docs.rs/mockall/latest/mockall/#static-methods - use super::*; - - use crate::windowing::MockErrorOperations; use mockall::predicate; use serial_test::serial; use windows::Win32::Foundation::WIN32_ERROR; + use super::*; + use crate::windowing::MockErrorOperations; + #[test] #[serial] fn get_window_title_length_can_be_zero() { diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml index 576a7d048fc..6455142023a 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/Cargo.toml @@ -11,7 +11,7 @@ publish.workspace = true aes-gcm = { workspace = true } chacha20poly1305 = { workspace = true } chromium_importer = { path = "../chromium_importer" } -clap = { version = "=4.5.40", features = ["derive"] } +clap = { version = "=4.5.51", features = ["derive"] } scopeguard = { workspace = true } sysinfo = { workspace = true } windows = { workspace = true, features = [ diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/crypto.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/crypto.rs index 094dbf94a67..c335a4b296a 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/crypto.rs +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/crypto.rs @@ -95,7 +95,8 @@ pub(crate) fn decode_abe_key_blob(blob_data: &[u8]) -> Result> { let content_offset = content_len_offset + 4; let content = get_safe(blob_data, content_offset, content_len)?; - // When the size is exactly 32 bytes, it's a plain key. It's used in unbranded Chromium builds, Brave, possibly Edge + // When the size is exactly 32 bytes, it's a plain key. It's used in unbranded Chromium builds, + // Brave, possibly Edge if content_len == 32 { return Ok(content.to_vec()); } diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/impersonate.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/impersonate.rs index 5a5109b9d32..22006b8db14 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/impersonate.rs +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/impersonate.rs @@ -30,7 +30,8 @@ pub(crate) fn start_impersonating() -> Result { // Need to enable SE_DEBUG_PRIVILEGE to enumerate and open SYSTEM processes enable_debug_privilege()?; - // Find a SYSTEM process and get its token. Not every SYSTEM process allows token duplication, so try several. + // Find a SYSTEM process and get its token. Not every SYSTEM process allows token duplication, + // so try several. let (token, pid, name) = find_system_process_with_token(get_system_pid_list())?; // Impersonate the SYSTEM process diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/log.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/log.rs index 7ee34a4160e..aa00a2f61b7 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/log.rs +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/log.rs @@ -1,13 +1,13 @@ +use chromium_importer::config::{ENABLE_DEVELOPER_LOGGING, LOG_FILENAME}; use tracing::{error, level_filters::LevelFilter}; use tracing_subscriber::{ fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _, }; -use chromium_importer::config::{ENABLE_DEVELOPER_LOGGING, LOG_FILENAME}; - pub(crate) fn init_logging() { if ENABLE_DEVELOPER_LOGGING { - // We only log to a file. It's impossible to see stdout/stderr when this exe is launched from ShellExecuteW. + // We only log to a file. It's impossible to see stdout/stderr when this exe is launched + // from ShellExecuteW. match std::fs::File::create(LOG_FILENAME) { Ok(file) => { let file_filter = EnvFilter::builder() diff --git a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/main.rs b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/main.rs index e178a8accf7..560135b8ce4 100644 --- a/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/main.rs +++ b/apps/desktop/desktop_native/bitwarden_chromium_import_helper/src/windows/main.rs @@ -1,12 +1,14 @@ -use anyhow::{anyhow, Result}; -use clap::Parser; -use scopeguard::defer; use std::{ ffi::OsString, os::windows::{ffi::OsStringExt as _, io::AsRawHandle}, path::PathBuf, time::Duration, }; + +use anyhow::{anyhow, Result}; +use chromium_importer::chromium::{verify_signature, ADMIN_TO_USER_PIPE_NAME}; +use clap::Parser; +use scopeguard::defer; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::windows::named_pipe::{ClientOptions, NamedPipeClient}, @@ -25,8 +27,6 @@ use windows::Win32::{ UI::Shell::IsUserAnAdmin, }; -use chromium_importer::chromium::{verify_signature, ADMIN_TO_USER_PIPE_NAME}; - use super::{ crypto::{ decode_abe_key_blob, decode_base64, decrypt_with_dpapi_as_system, diff --git a/apps/desktop/desktop_native/chromium_importer/Cargo.toml b/apps/desktop/desktop_native/chromium_importer/Cargo.toml index 0cdd7ded555..ec545ad6e39 100644 --- a/apps/desktop/desktop_native/chromium_importer/Cargo.toml +++ b/apps/desktop/desktop_native/chromium_importer/Cargo.toml @@ -7,36 +7,39 @@ publish = { workspace = true } [dependencies] aes = { workspace = true } -aes-gcm = { workspace = true } anyhow = { workspace = true } -async-trait = "=0.1.88" -base64 = { workspace = true } -cbc = { workspace = true, features = ["alloc"] } +async-trait = "=0.1.89" dirs = { workspace = true } hex = { workspace = true } -pbkdf2 = "=0.12.2" rand = { workspace = true } rusqlite = { version = "=0.37.0", features = ["bundled"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -sha1 = "=0.10.6" -tokio = { workspace = true, features = ["full"] } -tracing = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] +cbc = { workspace = true, features = ["alloc"] } +pbkdf2 = "=0.12.2" security-framework = { workspace = true } libc = { workspace = true } +sha1 = "=0.10.6" [target.'cfg(target_os = "windows")'.dependencies] +aes-gcm = { workspace = true } +base64 = { workspace = true } windows = { workspace = true, features = [ "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } verifysign = "=0.2.4" +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] +cbc = { workspace = true, features = ["alloc"] } oo7 = { workspace = true } +pbkdf2 = "=0.12.2" +sha1 = "=0.10.6" [lints] workspace = true diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs index 952618cc853..51fa3854773 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/mod.rs @@ -1,6 +1,8 @@ -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::LazyLock; +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::LazyLock, +}; use anyhow::{anyhow, Result}; use async_trait::async_trait; @@ -10,11 +12,10 @@ use rusqlite::{params, Connection}; mod platform; +pub(crate) use platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS; #[cfg(target_os = "windows")] pub use platform::*; -pub(crate) use platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS; - // // Public API // @@ -111,14 +112,15 @@ pub async fn import_logins( let local_logins = get_logins(&data_dir, profile_id, "Login Data") .map_err(|e| anyhow!("Failed to query logins: {}", e))?; - // This is not available in all browsers, but there's no harm in trying. If the file doesn't exist we just get an empty vector. + // This is not available in all browsers, but there's no harm in trying. If the file doesn't + // exist we just get an empty vector. let account_logins = get_logins(&data_dir, profile_id, "Login Data For Account") .map_err(|e| anyhow!("Failed to query logins: {}", e))?; // TODO: Do we need a better merge strategy? Maybe ignore duplicates at least? - // TODO: Should we also ignore an error from one of the two imports? If one is successful and the other fails, - // should we still return the successful ones? At the moment it doesn't fail for a missing file, only when - // something goes really wrong. + // TODO: Should we also ignore an error from one of the two imports? If one is successful and + // the other fails, should we still return the successful ones? At the moment it + // doesn't fail for a missing file, only when something goes really wrong. let all_logins = local_logins .into_iter() .chain(account_logins.into_iter()) diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs index 227dffdcca7..14e38797640 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/linux.rs @@ -4,15 +4,17 @@ use anyhow::{anyhow, Result}; use async_trait::async_trait; use oo7::XDG_SCHEMA_ATTRIBUTE; -use crate::chromium::{BrowserConfig, CryptoService, LocalState}; - -use crate::util; +use crate::{ + chromium::{BrowserConfig, CryptoService, LocalState}, + util, +}; // // Public API // -// TODO: It's possible that there might be multiple possible data directories, depending on the installation method (e.g., snap, flatpak, etc.). +// TODO: It's possible that there might be multiple possible data directories, depending on the +// installation method (e.g., snap, flatpak, etc.). pub(crate) const SUPPORTED_BROWSERS: &[BrowserConfig] = &[ BrowserConfig { name: "Chrome", diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs index a89f0b5ee78..ce991876026 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/macos.rs @@ -7,8 +7,10 @@ use std::ffi::CString; #[cfg(feature = "sandbox")] use std::os::raw::c_char; -use crate::chromium::{BrowserConfig, CryptoService, LocalState}; -use crate::util; +use crate::{ + chromium::{BrowserConfig, CryptoService, LocalState}, + util, +}; // // Sandbox specific (for Mac App Store Builds) diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe.rs index 943727690f2..a76f7b95e5c 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/abe.rs @@ -1,6 +1,6 @@ -use super::abe_config; -use anyhow::{anyhow, Result}; use std::{ffi::OsStr, os::windows::ffi::OsStrExt}; + +use anyhow::{anyhow, Result}; use tokio::{ io::{self, AsyncReadExt, AsyncWriteExt}, net::windows::named_pipe::{NamedPipeServer, ServerOptions}, @@ -14,6 +14,8 @@ use windows::{ Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_HIDE}, }; +use super::abe_config; + const WAIT_FOR_ADMIN_MESSAGE_TIMEOUT_SECS: u64 = 30; fn start_tokio_named_pipe_server( diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/mod.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/mod.rs index 867104d9bfd..9cc89ed2161 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/mod.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/mod.rs @@ -1,11 +1,14 @@ +use std::path::{Path, PathBuf}; + use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit, Nonce}; use anyhow::{anyhow, Result}; use async_trait::async_trait; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use std::path::{Path, PathBuf}; -use crate::chromium::{BrowserConfig, CryptoService, LocalState}; -use crate::util; +use crate::{ + chromium::{BrowserConfig, CryptoService, LocalState}, + util, +}; mod abe; mod abe_config; mod crypto; @@ -95,7 +98,8 @@ impl CryptoService for WindowsCryptoService { let (version, no_prefix) = util::split_encrypted_string_and_validate(encrypted, &["v10", "v20"])?; - // v10 is already stripped; Windows Chrome uses AES-GCM: [12 bytes IV][ciphertext][16 bytes auth tag] + // v10 is already stripped; Windows Chrome uses AES-GCM: [12 bytes IV][ciphertext][16 bytes + // auth tag] const IV_SIZE: usize = 12; const TAG_SIZE: usize = 16; const MIN_LENGTH: usize = IV_SIZE + TAG_SIZE; @@ -242,8 +246,8 @@ fn get_dist_admin_exe_path(current_exe_full_path: &Path) -> Result { Ok(admin_exe) } -// Try to find bitwarden_chromium_import_helper.exe in debug build folders. This might not cover all the cases. -// Tested on `npm run electron` from apps/desktop and apps/desktop/desktop_native. +// Try to find bitwarden_chromium_import_helper.exe in debug build folders. This might not cover all +// the cases. Tested on `npm run electron` from apps/desktop and apps/desktop/desktop_native. fn get_debug_admin_exe_path() -> Result { let current_dir = std::env::current_dir()?; let folder_name = current_dir diff --git a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/signature.rs b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/signature.rs index d5d6c5d6d15..97cf57935b2 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/signature.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/chromium/platform/windows/signature.rs @@ -1,5 +1,6 @@ -use anyhow::{anyhow, Result}; use std::path::Path; + +use anyhow::{anyhow, Result}; use tracing::{debug, info}; use verifysign::CodeSignVerifier; diff --git a/apps/desktop/desktop_native/chromium_importer/src/metadata.rs b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs index bfd7f184621..114c9f8df84 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/metadata.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/metadata.rs @@ -59,9 +59,9 @@ pub fn get_supported_importers( // Tests are cfg-gated based upon OS, and must be compiled/run on each OS for full coverage #[cfg(test)] mod tests { - use super::*; use std::collections::HashSet; + use super::*; use crate::chromium::{InstalledBrowserRetriever, SUPPORTED_BROWSER_MAP}; pub struct MockInstalledBrowserRetriever {} diff --git a/apps/desktop/desktop_native/chromium_importer/src/util.rs b/apps/desktop/desktop_native/chromium_importer/src/util.rs index f346d7e6dd0..2dbc6ed005b 100644 --- a/apps/desktop/desktop_native/chromium_importer/src/util.rs +++ b/apps/desktop/desktop_native/chromium_importer/src/util.rs @@ -32,7 +32,7 @@ pub(crate) fn split_encrypted_string_and_validate<'a>( } /// Decrypt using AES-128 in CBC mode. -#[cfg(any(target_os = "linux", target_os = "macos", test))] +#[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result> { use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; @@ -41,7 +41,8 @@ pub(crate) fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> R .map_err(|e| anyhow!("Failed to decrypt: {}", e)) } -/// Derives a PBKDF2 key from the static "saltysalt" salt with the given password and iteration count. +/// Derives a PBKDF2 key from the static "saltysalt" salt with the given password and iteration +/// count. #[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) fn derive_saltysalt(password: &[u8], iterations: u32) -> Result> { use pbkdf2::{hmac::Hmac, pbkdf2}; @@ -55,27 +56,9 @@ pub(crate) fn derive_saltysalt(password: &[u8], iterations: u32) -> Result Vec { - (0..length).map(|i| offset + i as u8 * increment).collect() - } - - fn generate_generic_array>( - offset: u8, - increment: u8, - ) -> GenericArray { - GenericArray::generate(|i| offset + i as u8 * increment) - } - fn run_split_encrypted_string_test<'a, const N: usize>( successfully_split: bool, plaintext_to_encrypt: &'a str, @@ -144,8 +127,28 @@ mod tests { run_split_encrypted_string_and_validate_test(false, "v10EncryptMe!", &[]); } + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn test_decrypt_aes_128_cbc() { + use aes::cipher::{ + block_padding::Pkcs7, + generic_array::{sequence::GenericSequence, GenericArray}, + ArrayLength, BlockEncryptMut, KeyIvInit, + }; + + const LENGTH16: usize = 16; + + fn generate_generic_array>( + offset: u8, + increment: u8, + ) -> GenericArray { + GenericArray::generate(|i| offset + i as u8 * increment) + } + + fn generate_vec(length: usize, offset: u8, increment: u8) -> Vec { + (0..length).map(|i| offset + i as u8 * increment).collect() + } + let offset = 0; let increment = 1; diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index f6c9d669df6..dc9246f55c6 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -23,27 +23,15 @@ anyhow = { workspace = true } arboard = { workspace = true, features = ["wayland-data-control"] } base64 = { workspace = true } bitwarden-russh = { workspace = true } -byteorder = { workspace = true } bytes = { workspace = true } cbc = { workspace = true, features = ["alloc"] } chacha20poly1305 = { workspace = true } dirs = { workspace = true } -ed25519 = { workspace = true, features = ["pkcs8"] } futures = { workspace = true } -homedir = { workspace = true } interprocess = { workspace = true, features = ["tokio"] } memsec = { workspace = true, features = ["alloc_ext"] } -pin-project = { workspace = true } -pkcs8 = { workspace = true, features = ["alloc", "encryption", "pem"] } rand = { workspace = true } -rsa = { workspace = true } -russh-cryptovec = { workspace = true } -scopeguard = { workspace = true } -secmem-proc = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } sha2 = { workspace = true } -ssh-encoding = { workspace = true } ssh-key = { workspace = true, features = [ "encryption", "ed25519", @@ -53,13 +41,17 @@ ssh-key = { workspace = true, features = [ sysinfo = { workspace = true, features = ["windows"] } thiserror = { workspace = true } tokio = { workspace = true, features = ["io-util", "sync", "macros", "net"] } -tokio-stream = { workspace = true, features = ["net"] } tokio-util = { workspace = true, features = ["codec"] } tracing = { workspace = true } typenum = { workspace = true } zeroizing-alloc = { workspace = true } [target.'cfg(windows)'.dependencies] +pin-project = { workspace = true } +scopeguard = { workspace = true } +secmem-proc = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } widestring = { workspace = true, optional = true } windows = { workspace = true, features = [ "Foundation", @@ -76,21 +68,20 @@ windows = { workspace = true, features = [ ], optional = true } windows-future = { workspace = true } -[target.'cfg(windows)'.dev-dependencies] -keytar = { workspace = true } - [target.'cfg(target_os = "macos")'.dependencies] core-foundation = { workspace = true, optional = true } +homedir = { workspace = true } +secmem-proc = { workspace = true } security-framework = { workspace = true, optional = true } security-framework-sys = { workspace = true, optional = true } desktop_objc = { path = "../objc" } [target.'cfg(target_os = "linux")'.dependencies] -oo7 = { workspace = true } +ashpd = { workspace = true } +homedir = { workspace = true } libc = { workspace = true } linux-keyutils = { workspace = true } -ashpd = { workspace = true } - +oo7 = { workspace = true } zbus = { workspace = true, optional = true } zbus_polkit = { workspace = true, optional = true } diff --git a/apps/desktop/desktop_native/core/src/biometric/mod.rs b/apps/desktop/desktop_native/core/src/biometric/mod.rs index e4d51f5da9a..937c67ff30a 100644 --- a/apps/desktop/desktop_native/core/src/biometric/mod.rs +++ b/apps/desktop/desktop_native/core/src/biometric/mod.rs @@ -86,11 +86,15 @@ impl KeyMaterial { #[cfg(test)] mod tests { - use crate::biometric::{decrypt, encrypt, KeyMaterial}; - use crate::crypto::CipherString; - use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; use std::str::FromStr; + use base64::{engine::general_purpose::STANDARD as base64_engine, Engine}; + + use crate::{ + biometric::{decrypt, encrypt, KeyMaterial}, + crypto::CipherString, + }; + fn key_material() -> KeyMaterial { KeyMaterial { os_key_part_b64: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_owned(), diff --git a/apps/desktop/desktop_native/core/src/biometric/unix.rs b/apps/desktop/desktop_native/core/src/biometric/unix.rs index 0f6ff8f33dc..3f4f10a1fcf 100644 --- a/apps/desktop/desktop_native/core/src/biometric/unix.rs +++ b/apps/desktop/desktop_native/core/src/biometric/unix.rs @@ -1,18 +1,18 @@ use std::str::FromStr; -use anyhow::Result; +use anyhow::{anyhow, Result}; use base64::Engine; use rand::RngCore; use sha2::{Digest, Sha256}; use tracing::error; - -use crate::biometric::{base64_engine, KeyMaterial, OsDerivedKey}; use zbus::Connection; use zbus_polkit::policykit1::*; use super::{decrypt, encrypt}; -use crate::crypto::CipherString; -use anyhow::anyhow; +use crate::{ + biometric::{base64_engine, KeyMaterial, OsDerivedKey}, + crypto::CipherString, +}; /// The Unix implementation of the biometric trait. pub struct Biometric {} diff --git a/apps/desktop/desktop_native/core/src/biometric/windows.rs b/apps/desktop/desktop_native/core/src/biometric/windows.rs index 8013c21bf9a..f72282d9284 100644 --- a/apps/desktop/desktop_native/core/src/biometric/windows.rs +++ b/apps/desktop/desktop_native/core/src/biometric/windows.rs @@ -16,13 +16,12 @@ use windows::{ }; use windows_future::IAsyncOperation; +use super::{decrypt, encrypt, windows_focus::set_focus}; use crate::{ biometric::{KeyMaterial, OsDerivedKey}, crypto::CipherString, }; -use super::{decrypt, encrypt, windows_focus::set_focus}; - /// The Windows OS implementation of the biometric trait. pub struct Biometric {} @@ -61,7 +60,8 @@ impl super::BiometricTrait for Biometric { match ucv_available { UserConsentVerifierAvailability::Available => Ok(true), - UserConsentVerifierAvailability::DeviceBusy => Ok(true), // TODO: Look into removing this and making the check more ad-hoc + // TODO: look into removing this and making the check more ad-hoc + UserConsentVerifierAvailability::DeviceBusy => Ok(true), _ => Ok(false), } } @@ -133,7 +133,6 @@ fn random_challenge() -> [u8; 16] { #[cfg(test)] mod tests { use super::*; - use crate::biometric::BiometricTrait; #[test] diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs b/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs index 44cba4a9e5b..ff2abc0686b 100644 --- a/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs +++ b/apps/desktop/desktop_native/core/src/biometric_v2/linux.rs @@ -1,17 +1,19 @@ //! This file implements Polkit based system unlock. //! //! # Security -//! This section describes the assumed security model and security guarantees achieved. In the required security -//! guarantee is that a locked vault - a running app - cannot be unlocked when the device (user-space) -//! is compromised in this state. +//! This section describes the assumed security model and security guarantees achieved. In the +//! required security guarantee is that a locked vault - a running app - cannot be unlocked when the +//! device (user-space) is compromised in this state. //! -//! When first unlocking the app, the app sends the user-key to this module, which holds it in secure memory, -//! protected by memfd_secret. This makes it inaccessible to other processes, even if they compromise root, a kernel compromise -//! has circumventable best-effort protections. While the app is running this key is held in memory, even if locked. -//! When unlocking, the app will prompt the user via `polkit` to get a yes/no decision on whether to release the key to the app. +//! When first unlocking the app, the app sends the user-key to this module, which holds it in +//! secure memory, protected by memfd_secret. This makes it inaccessible to other processes, even if +//! they compromise root, a kernel compromise has circumventable best-effort protections. While the +//! app is running this key is held in memory, even if locked. When unlocking, the app will prompt +//! the user via `polkit` to get a yes/no decision on whether to release the key to the app. + +use std::sync::Arc; use anyhow::{anyhow, Result}; -use std::sync::Arc; use tokio::sync::Mutex; use tracing::{debug, warn}; use zbus::Connection; @@ -20,8 +22,8 @@ use zbus_polkit::policykit1::{AuthorityProxy, CheckAuthorizationFlags, Subject}; use crate::secure_memory::*; pub struct BiometricLockSystem { - // The userkeys that are held in memory MUST be protected from memory dumping attacks, to ensure - // locked vaults cannot be unlocked + // The userkeys that are held in memory MUST be protected from memory dumping attacks, to + // ensure locked vaults cannot be unlocked secure_memory: Arc>, } @@ -88,8 +90,9 @@ impl super::BiometricTrait for BiometricLockSystem { } } -/// Perform a polkit authorization against the bitwarden unlock policy. Note: This relies on no custom -/// rules in the system skipping the authorization check, in which case this counts as UV / authentication. +/// Perform a polkit authorization against the bitwarden unlock policy. Note: This relies on no +/// custom rules in the system skipping the authorization check, in which case this counts as UV / +/// authentication. async fn polkit_authenticate_bitwarden_policy() -> Result { debug!("[Polkit] Authenticating / performing UV"); diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs index 669267b7829..55aee27dd33 100644 --- a/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs +++ b/apps/desktop/desktop_native/core/src/biometric_v2/mod.rs @@ -17,8 +17,8 @@ pub trait BiometricTrait: Send + Sync { async fn authenticate(&self, hwnd: Vec, message: String) -> Result; /// Check if biometric authentication is available async fn authenticate_available(&self) -> Result; - /// Enroll a key for persistent unlock. If the implementation does not support persistent enrollment, - /// this function should do nothing. + /// Enroll a key for persistent unlock. If the implementation does not support persistent + /// enrollment, this function should do nothing. async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()>; /// Clear the persistent and ephemeral keys async fn unenroll(&self, user_id: &str) -> Result<()>; @@ -28,6 +28,7 @@ pub trait BiometricTrait: Send + Sync { async fn provide_key(&self, user_id: &str, key: &[u8]); /// Perform biometric unlock and return the key async fn unlock(&self, user_id: &str, hwnd: Vec) -> Result>; - /// Check if biometric unlock is available based on whether a key is present and whether authentication is possible + /// Check if biometric unlock is available based on whether a key is present and whether + /// authentication is possible async fn unlock_available(&self, user_id: &str) -> Result; } diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs b/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs index 043c2453cd0..32d2eb7e6e6 100644 --- a/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows.rs @@ -2,38 +2,40 @@ //! //! There are two paths implemented here. //! The former via UV + ephemerally (but protected) keys. This only works after first unlock. -//! The latter via a signing API, that deterministically signs a challenge, from which a windows hello key is derived. This key -//! is used to encrypt the protected key. +//! The latter via a signing API, that deterministically signs a challenge, from which a windows +//! hello key is derived. This key is used to encrypt the protected key. //! //! # Security -//! The security goal is that a locked vault - a running app - cannot be unlocked when the device (user-space) -//! is compromised in this state. +//! The security goal is that a locked vault - a running app - cannot be unlocked when the device +//! (user-space) is compromised in this state. //! //! ## UV path -//! When first unlocking the app, the app sends the user-key to this module, which holds it in secure memory, -//! protected by DPAPI. This makes it inaccessible to other processes, unless they compromise the system administrator, or kernel. -//! While the app is running this key is held in memory, even if locked. When unlocking, the app will prompt the user via +//! When first unlocking the app, the app sends the user-key to this module, which holds it in +//! secure memory, protected by DPAPI. This makes it inaccessible to other processes, unless they +//! compromise the system administrator, or kernel. While the app is running this key is held in +//! memory, even if locked. When unlocking, the app will prompt the user via //! `windows_hello_authenticate` to get a yes/no decision on whether to release the key to the app. -//! Note: Further process isolation is needed here so that code cannot be injected into the running process, which may -//! circumvent DPAPI. +//! Note: Further process isolation is needed here so that code cannot be injected into the running +//! process, which may circumvent DPAPI. //! //! ## Sign path -//! In this scenario, when enrolling, the app sends the user-key to this module, which derives the windows hello key -//! with the Windows Hello prompt. This is done by signing a per-user challenge, which produces a deterministic -//! signature which is hashed to obtain a key. This key is used to encrypt and persist the vault unlock key (user key). +//! In this scenario, when enrolling, the app sends the user-key to this module, which derives the +//! windows hello key with the Windows Hello prompt. This is done by signing a per-user challenge, +//! which produces a deterministic signature which is hashed to obtain a key. This key is used to +//! encrypt and persist the vault unlock key (user key). //! -//! Since the keychain can be accessed by all user-space processes, the challenge is known to all userspace processes. -//! Therefore, to circumvent the security measure, the attacker would need to create a fake Windows-Hello prompt, and -//! get the user to confirm it. +//! Since the keychain can be accessed by all user-space processes, the challenge is known to all +//! userspace processes. Therefore, to circumvent the security measure, the attacker would need to +//! create a fake Windows-Hello prompt, and get the user to confirm it. use std::sync::{atomic::AtomicBool, Arc}; -use tracing::{debug, warn}; use aes::cipher::KeyInit; use anyhow::{anyhow, Result}; use chacha20poly1305::{aead::Aead, XChaCha20Poly1305, XNonce}; use sha2::{Digest, Sha256}; use tokio::sync::Mutex; +use tracing::{debug, warn}; use windows::{ core::{factory, h, Interface, HSTRING}, Security::{ @@ -74,8 +76,8 @@ struct WindowsHelloKeychainEntry { /// The Windows OS implementation of the biometric trait. pub struct BiometricLockSystem { - // The userkeys that are held in memory MUST be protected from memory dumping attacks, to ensure - // locked vaults cannot be unlocked + // The userkeys that are held in memory MUST be protected from memory dumping attacks, to + // ensure locked vaults cannot be unlocked secure_memory: Arc>, } @@ -114,12 +116,14 @@ impl super::BiometricTrait for BiometricLockSystem { } async fn enroll_persistent(&self, user_id: &str, key: &[u8]) -> Result<()> { - // Enrollment works by first generating a random challenge unique to the user / enrollment. Then, - // with the challenge and a Windows-Hello prompt, the "windows hello key" is derived. The windows - // hello key is used to encrypt the key to store with XChaCha20Poly1305. The bundle of nonce, - // challenge and wrapped-key are stored to the keychain + // Enrollment works by first generating a random challenge unique to the user / enrollment. + // Then, with the challenge and a Windows-Hello prompt, the "windows hello key" is + // derived. The windows hello key is used to encrypt the key to store with + // XChaCha20Poly1305. The bundle of nonce, challenge and wrapped-key are stored to + // the keychain - // Each enrollment (per user) has a unique challenge, so that the windows-hello key is unique + // Each enrollment (per user) has a unique challenge, so that the windows-hello key is + // unique let challenge: [u8; CHALLENGE_LENGTH] = rand::random(); // This key is unique to the challenge @@ -155,8 +159,8 @@ impl super::BiometricTrait for BiometricLockSystem { }); let mut secure_memory = self.secure_memory.lock().await; - // If the key is held ephemerally, always use UV API. Only use signing API if the key is not held - // ephemerally but the keychain holds it persistently. + // If the key is held ephemerally, always use UV API. Only use signing API if the key is not + // held ephemerally but the keychain holds it persistently. if secure_memory.has(user_id) { if windows_hello_authenticate("Unlock your vault".to_string()).await? { secure_memory @@ -175,7 +179,8 @@ impl super::BiometricTrait for BiometricLockSystem { &keychain_entry.wrapped_key, &keychain_entry.nonce, )?; - // The first unlock already sets the key for subsequent unlocks. The key may again be set externally after unlock finishes. + // The first unlock already sets the key for subsequent unlocks. The key may again be + // set externally after unlock finishes. secure_memory.put(user_id.to_string(), &decrypted_key.clone()); Ok(decrypted_key) } @@ -231,8 +236,8 @@ async fn windows_hello_authenticate_with_crypto( ) -> Result<[u8; XCHACHA20POLY1305_KEY_LENGTH]> { debug!("[Windows Hello] Authenticating to sign challenge"); - // Ugly hack: We need to focus the window via window focusing APIs until Microsoft releases a new API. - // This is unreliable, and if it does not work, the operation may fail + // Ugly hack: We need to focus the window via window focusing APIs until Microsoft releases a + // new API. This is unreliable, and if it does not work, the operation may fail let stop_focusing = Arc::new(AtomicBool::new(false)); let stop_focusing_clone = stop_focusing.clone(); let _ = std::thread::spawn(move || loop { @@ -243,8 +248,8 @@ async fn windows_hello_authenticate_with_crypto( break; } }); - // Only stop focusing once this function exits. The focus MUST run both during the initial creation - // with RequestCreateAsync, and also with the subsequent use with RequestSignAsync. + // Only stop focusing once this function exits. The focus MUST run both during the initial + // creation with RequestCreateAsync, and also with the subsequent use with RequestSignAsync. let _guard = scopeguard::guard((), |_| { stop_focusing.store(true, std::sync::atomic::Ordering::Relaxed); }); @@ -283,8 +288,8 @@ async fn windows_hello_authenticate_with_crypto( let signature_buffer = signature.Result()?; let signature_value = unsafe { as_mut_bytes(&signature_buffer)? }; - // The signature is deterministic based on the challenge and keychain key. Thus, it can be hashed to a key. - // It is unclear what entropy this key provides. + // The signature is deterministic based on the challenge and keychain key. Thus, it can be + // hashed to a key. It is unclear what entropy this key provides. let windows_hello_key = Sha256::digest(signature_value).into(); Ok(windows_hello_key) } diff --git a/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs b/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs index f3ffb6e4ebe..bf303c88e01 100644 --- a/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs +++ b/apps/desktop/desktop_native/core/src/biometric_v2/windows_focus.rs @@ -34,23 +34,25 @@ pub fn focus_security_prompt() { /// Sets focus to a window using a few unstable methods fn set_focus(hwnd: HWND) { unsafe { - // Windows REALLY does not like apps stealing focus, even if it is for fixing Windows-Hello bugs. - // The windows hello signing prompt NEEDS to be focused instantly, or it will error, but it does - // not focus itself. + // Windows REALLY does not like apps stealing focus, even if it is for fixing Windows-Hello + // bugs. The windows hello signing prompt NEEDS to be focused instantly, or it will + // error, but it does not focus itself. // This function implements forced focusing of windows using a few hacks. // The conditions to successfully foreground a window are: // All of the following conditions are true: - // The calling process belongs to a desktop application, not a UWP app or a Windows Store app designed for Windows 8 or 8.1. - // The foreground process has not disabled calls to SetForegroundWindow by a previous call to the LockSetForegroundWindow function. - // The foreground lock time-out has expired (see SPI_GETFOREGROUNDLOCKTIMEOUT in SystemParametersInfo). - // No menus are active. + // - The calling process belongs to a desktop application, not a UWP app or a Windows + // Store app designed for Windows 8 or 8.1. + // - The foreground process has not disabled calls to SetForegroundWindow by a previous + // call to the LockSetForegroundWindow function. + // - The foreground lock time-out has expired (see SPI_GETFOREGROUNDLOCKTIMEOUT in + // SystemParametersInfo). No menus are active. // Additionally, at least one of the following conditions is true: - // The calling process is the foreground process. - // The calling process was started by the foreground process. - // There is currently no foreground window, and thus no foreground process. - // The calling process received the last input event. - // Either the foreground process or the calling process is being debugged. + // - The calling process is the foreground process. + // - The calling process was started by the foreground process. + // - There is currently no foreground window, and thus no foreground process. + // - The calling process received the last input event. + // - Either the foreground process or the calling process is being debugged. // Update the foreground lock timeout temporarily let mut old_timeout = 0; @@ -75,7 +77,8 @@ fn set_focus(hwnd: HWND) { ); }); - // Attach to the foreground thread once attached, we can foreground, even if in the background + // Attach to the foreground thread once attached, we can foreground, even if in the + // background let dw_current_thread = GetCurrentThreadId(); let dw_fg_thread = GetWindowThreadProcessId(GetForegroundWindow(), None); @@ -91,7 +94,8 @@ fn set_focus(hwnd: HWND) { } } -/// When restoring focus to the application window, we need a less aggressive method so the electron window doesn't get frozen. +/// When restoring focus to the application window, we need a less aggressive method so the electron +/// window doesn't get frozen. pub(crate) fn restore_focus(hwnd: HWND) { unsafe { let _ = SetForegroundWindow(hwnd); diff --git a/apps/desktop/desktop_native/core/src/crypto/crypto.rs b/apps/desktop/desktop_native/core/src/crypto/crypto.rs index d9e2aec3046..7991c87ca28 100644 --- a/apps/desktop/desktop_native/core/src/crypto/crypto.rs +++ b/apps/desktop/desktop_native/core/src/crypto/crypto.rs @@ -5,9 +5,8 @@ use aes::cipher::{ BlockEncryptMut, KeyIvInit, }; -use crate::error::{CryptoError, Result}; - use super::CipherString; +use crate::error::{CryptoError, Result}; pub fn decrypt_aes256(iv: &[u8; 16], data: &[u8], key: GenericArray) -> Result> { let iv = GenericArray::from_slice(iv); @@ -16,7 +15,8 @@ pub fn decrypt_aes256(iv: &[u8; 16], data: &[u8], key: GenericArray) -> .decrypt_padded_mut::(&mut data) .map_err(|_| CryptoError::KeyDecrypt)?; - // Data is decrypted in place and returns a subslice of the original Vec, to avoid cloning it, we truncate to the subslice length + // Data is decrypted in place and returns a subslice of the original Vec, to avoid cloning it, + // we truncate to the subslice length let decrypted_len = decrypted_key_slice.len(); data.truncate(decrypted_len); diff --git a/apps/desktop/desktop_native/core/src/error.rs b/apps/desktop/desktop_native/core/src/error.rs index d70d8624018..c8d3ec02332 100644 --- a/apps/desktop/desktop_native/core/src/error.rs +++ b/apps/desktop/desktop_native/core/src/error.rs @@ -35,15 +35,4 @@ pub enum KdfParamError { InvalidParams(String), } -// Ensure that the error messages implement Send and Sync -#[cfg(test)] -const _: () = { - fn assert_send() {} - fn assert_sync() {} - fn assert_all() { - assert_send::(); - assert_sync::(); - } -}; - pub type Result = std::result::Result; diff --git a/apps/desktop/desktop_native/core/src/ipc/mod.rs b/apps/desktop/desktop_native/core/src/ipc/mod.rs index 5d4cc9e27f7..f806e395d10 100644 --- a/apps/desktop/desktop_native/core/src/ipc/mod.rs +++ b/apps/desktop/desktop_native/core/src/ipc/mod.rs @@ -49,7 +49,8 @@ pub fn path(name: &str) -> std::path::PathBuf { #[cfg(target_os = "macos")] { // When running in an unsandboxed environment, path is: /Users// - // While running sandboxed, it's different: /Users//Library/Containers/com.bitwarden.desktop/Data + // While running sandboxed, it's different: + // /Users//Library/Containers/com.bitwarden.desktop/Data let mut home = dirs::home_dir().unwrap(); // Check if the app is sandboxed by looking for the Containers directory @@ -59,8 +60,9 @@ pub fn path(name: &str) -> std::path::PathBuf { // If the app is sanboxed, we need to use the App Group directory if let Some(position) = containers_position { - // We want to use App Groups in /Users//Library/Group Containers/LTZ2PFU5D6.com.bitwarden.desktop, - // so we need to remove all the components after the user. We can use the previous position to do this. + // We want to use App Groups in /Users//Library/Group + // Containers/LTZ2PFU5D6.com.bitwarden.desktop, so we need to remove all the + // components after the user. We can use the previous position to do this. while home.components().count() > position - 1 { home.pop(); } diff --git a/apps/desktop/desktop_native/core/src/ipc/server.rs b/apps/desktop/desktop_native/core/src/ipc/server.rs index 2762a832ac6..a65638303f1 100644 --- a/apps/desktop/desktop_native/core/src/ipc/server.rs +++ b/apps/desktop/desktop_native/core/src/ipc/server.rs @@ -3,9 +3,8 @@ use std::{ path::{Path, PathBuf}, }; -use futures::{SinkExt, StreamExt, TryFutureExt}; - use anyhow::Result; +use futures::{SinkExt, StreamExt, TryFutureExt}; use interprocess::local_socket::{tokio::prelude::*, GenericFilePath, ListenerOptions}; use tokio::{ io::{AsyncRead, AsyncWrite}, @@ -42,14 +41,17 @@ impl Server { /// /// # Parameters /// - /// - `name`: The endpoint name to listen on. This name uniquely identifies the IPC connection and must be the same for both the server and client. - /// - `client_to_server_send`: This [`mpsc::Sender`] will receive all the [`Message`]'s that the clients send to this server. + /// - `name`: The endpoint name to listen on. This name uniquely identifies the IPC connection + /// and must be the same for both the server and client. + /// - `client_to_server_send`: This [`mpsc::Sender`] will receive all the [`Message`]'s + /// that the clients send to this server. pub fn start( path: &Path, client_to_server_send: mpsc::Sender, ) -> Result> { - // If the unix socket file already exists, we get an error when trying to bind to it. So we remove it first. - // Any processes that were using the old socket should remain connected to it but any new connections will use the new socket. + // If the unix socket file already exists, we get an error when trying to bind to it. So we + // remove it first. Any processes that were using the old socket should remain + // connected to it but any new connections will use the new socket. if !cfg!(windows) { let _ = std::fs::remove_file(path); } @@ -58,8 +60,9 @@ impl Server { let opts = ListenerOptions::new().name(name); let listener = opts.create_tokio()?; - // This broadcast channel is used for sending messages to all connected clients, and so the sender - // will be stored in the server while the receiver will be cloned and passed to each client handler. + // This broadcast channel is used for sending messages to all connected clients, and so the + // sender will be stored in the server while the receiver will be cloned and passed + // to each client handler. let (server_to_clients_send, server_to_clients_recv) = broadcast::channel::(MESSAGE_CHANNEL_BUFFER); diff --git a/apps/desktop/desktop_native/core/src/password/macos.rs b/apps/desktop/desktop_native/core/src/password/macos.rs index 4f3a16ba4be..72d8ebeb425 100644 --- a/apps/desktop/desktop_native/core/src/password/macos.rs +++ b/apps/desktop/desktop_native/core/src/password/macos.rs @@ -1,9 +1,10 @@ -use crate::password::PASSWORD_NOT_FOUND; use anyhow::Result; use security_framework::passwords::{ delete_generic_password, get_generic_password, set_generic_password, }; +use crate::password::PASSWORD_NOT_FOUND; + #[allow(clippy::unused_async)] pub async fn get_password(service: &str, account: &str) -> Result { let password = get_generic_password(service, account).map_err(convert_error)?; diff --git a/apps/desktop/desktop_native/core/src/password/unix.rs b/apps/desktop/desktop_native/core/src/password/unix.rs index b7595dca287..57b71adefed 100644 --- a/apps/desktop/desktop_native/core/src/password/unix.rs +++ b/apps/desktop/desktop_native/core/src/password/unix.rs @@ -1,9 +1,11 @@ -use crate::password::PASSWORD_NOT_FOUND; +use std::collections::HashMap; + use anyhow::{anyhow, Result}; use oo7::dbus::{self}; -use std::collections::HashMap; use tracing::info; +use crate::password::PASSWORD_NOT_FOUND; + pub async fn get_password(service: &str, account: &str) -> Result { match get_password_new(service, account).await { Ok(res) => Ok(res), diff --git a/apps/desktop/desktop_native/core/src/password/windows.rs b/apps/desktop/desktop_native/core/src/password/windows.rs index ad09019f014..645620b444e 100644 --- a/apps/desktop/desktop_native/core/src/password/windows.rs +++ b/apps/desktop/desktop_native/core/src/password/windows.rs @@ -1,4 +1,3 @@ -use crate::password::PASSWORD_NOT_FOUND; use anyhow::{anyhow, Result}; use widestring::{U16CString, U16String}; use windows::{ @@ -12,6 +11,8 @@ use windows::{ }, }; +use crate::password::PASSWORD_NOT_FOUND; + const CRED_FLAGS_NONE: u32 = 0; #[allow(clippy::unused_async)] diff --git a/apps/desktop/desktop_native/core/src/process_isolation/linux.rs b/apps/desktop/desktop_native/core/src/process_isolation/linux.rs index bad348c93e2..263cc10b716 100644 --- a/apps/desktop/desktop_native/core/src/process_isolation/linux.rs +++ b/apps/desktop/desktop_native/core/src/process_isolation/linux.rs @@ -4,15 +4,15 @@ use libc::c_uint; use libc::{self, c_int}; use tracing::info; -// RLIMIT_CORE is the maximum size of a core dump file. Setting both to 0 disables core dumps, on crashes -// https://github.com/torvalds/linux/blob/1613e604df0cd359cf2a7fbd9be7a0bcfacfabd0/include/uapi/asm-generic/resource.h#L20 +// RLIMIT_CORE is the maximum size of a core dump file. Setting both to 0 disables core dumps, on +// crashes https://github.com/torvalds/linux/blob/1613e604df0cd359cf2a7fbd9be7a0bcfacfabd0/include/uapi/asm-generic/resource.h#L20 #[cfg(target_env = "musl")] const RLIMIT_CORE: c_int = 4; #[cfg(target_env = "gnu")] const RLIMIT_CORE: c_uint = 4; -// PR_SET_DUMPABLE makes it so no other running process (root or same user) can dump the memory of this process -// or attach a debugger to it. +// PR_SET_DUMPABLE makes it so no other running process (root or same user) can dump the memory of +// this process or attach a debugger to it. // https://github.com/torvalds/linux/blob/a38297e3fb012ddfa7ce0321a7e5a8daeb1872b6/include/uapi/linux/prctl.h#L14 const PR_SET_DUMPABLE: c_int = 4; diff --git a/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs index 3ff8a6d3d83..8d8e10d92c4 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/dpapi.rs @@ -29,8 +29,9 @@ impl SecureMemoryStore for DpapiSecretKVStore { fn put(&mut self, key: String, value: &[u8]) { let length_header_len = std::mem::size_of::(); - // The allocated data has to be a multiple of CRYPTPROTECTMEMORY_BLOCK_SIZE, so we pad it and write the length in front - // We are storing LENGTH|DATA|00..00, where LENGTH is the length of DATA, the total length is a multiple + // The allocated data has to be a multiple of CRYPTPROTECTMEMORY_BLOCK_SIZE, so we pad it + // and write the length in front We are storing LENGTH|DATA|00..00, where LENGTH is + // the length of DATA, the total length is a multiple // of CRYPTPROTECTMEMORY_BLOCK_SIZE, and the padding is filled with zeros. let data_len = value.len(); diff --git a/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs b/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs index a8952d8f55a..d116e564bc8 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/encrypted_memory_store.rs @@ -10,8 +10,8 @@ use crate::secure_memory::{ /// allows circumventing length and amount limitations on platform specific secure memory APIs since /// only a single short item needs to be protected. /// -/// The key is briefly in process memory during encryption and decryption, in memory that is protected -/// from swapping to disk via mlock, and then zeroed out immediately after use. +/// The key is briefly in process memory during encryption and decryption, in memory that is +/// protected from swapping to disk via mlock, and then zeroed out immediately after use. #[allow(unused)] pub(crate) struct EncryptedMemoryStore { map: std::collections::HashMap, diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs index 1ee6c4cdf40..7e2917ade6d 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/crypto.rs @@ -6,9 +6,9 @@ use rand::{rng, Rng}; pub(super) const KEY_SIZE: usize = 32; pub(super) const NONCE_SIZE: usize = 24; -/// The encryption performed here is xchacha-poly1305. Any tampering with the key or the ciphertexts will result -/// in a decryption failure and panic. The key's memory contents are protected from being swapped to disk -/// via mlock. +/// The encryption performed here is xchacha-poly1305. Any tampering with the key or the ciphertexts +/// will result in a decryption failure and panic. The key's memory contents are protected from +/// being swapped to disk via mlock. pub(super) struct MemoryEncryptionKey(NonNull<[u8]>); /// An encrypted memory blob that must be decrypted using the same key that it was encrypted with. diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/dpapi.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/dpapi.rs index 0975b542877..52b75d94a09 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/dpapi.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/dpapi.rs @@ -1,10 +1,13 @@ -use super::crypto::{MemoryEncryptionKey, KEY_SIZE}; -use super::SecureKeyContainer; use windows::Win32::Security::Cryptography::{ CryptProtectMemory, CryptUnprotectMemory, CRYPTPROTECTMEMORY_BLOCK_SIZE, CRYPTPROTECTMEMORY_SAME_PROCESS, }; +use super::{ + crypto::{MemoryEncryptionKey, KEY_SIZE}, + SecureKeyContainer, +}; + /// https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata /// The DPAPI store encrypts data using the Windows Data Protection API (DPAPI). The key is bound /// to the current process, and cannot be decrypted by other user-mode processes. diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/keyctl.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/keyctl.rs index a738d964671..29c62759740 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/keyctl.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/keyctl.rs @@ -1,9 +1,8 @@ -use crate::secure_memory::secure_key::crypto::MemoryEncryptionKey; - -use super::crypto::KEY_SIZE; -use super::SecureKeyContainer; use linux_keyutils::{KeyRing, KeyRingIdentifier}; +use super::{crypto::KEY_SIZE, SecureKeyContainer}; +use crate::secure_memory::secure_key::crypto::MemoryEncryptionKey; + /// The keys are bound to the process keyring. const KEY_RING_IDENTIFIER: KeyRingIdentifier = KeyRingIdentifier::Process; /// This is an atomic global counter used to help generate unique key IDs @@ -26,9 +25,9 @@ pub(super) struct KeyctlSecureKeyContainer { id: String, } -// SAFETY: The key id is fully owned by this struct and not exposed or cloned, and cleaned up on drop. -// Further, since we use `KeyRingIdentifier::Process` and not `KeyRingIdentifier::Thread`, the key -// is accessible across threads within the same process bound. +// SAFETY: The key id is fully owned by this struct and not exposed or cloned, and cleaned up on +// drop. Further, since we use `KeyRingIdentifier::Process` and not `KeyRingIdentifier::Thread`, the +// key is accessible across threads within the same process bound. unsafe impl Send for KeyctlSecureKeyContainer {} // SAFETY: The container is non-mutable and thus safe to share between threads. unsafe impl Sync for KeyctlSecureKeyContainer {} diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/memfd_secret.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/memfd_secret.rs index 4e6a2c4d7ac..e9f96db3148 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/memfd_secret.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/memfd_secret.rs @@ -1,8 +1,9 @@ use std::{ptr::NonNull, sync::LazyLock}; -use super::crypto::MemoryEncryptionKey; -use super::crypto::KEY_SIZE; -use super::SecureKeyContainer; +use super::{ + crypto::{MemoryEncryptionKey, KEY_SIZE}, + SecureKeyContainer, +}; /// https://man.archlinux.org/man/memfd_secret.2.en /// The memfd_secret store protects the data using the `memfd_secret` syscall. The @@ -15,8 +16,8 @@ pub(super) struct MemfdSecretSecureKeyContainer { // SAFETY: The pointers in this struct are allocated by `memfd_secret`, and we have full ownership. // They are never exposed outside or cloned, and are cleaned up by drop. unsafe impl Send for MemfdSecretSecureKeyContainer {} -// SAFETY: The container is non-mutable and thus safe to share between threads. Further, memfd-secret -// is accessible across threads within the same process bound. +// SAFETY: The container is non-mutable and thus safe to share between threads. Further, +// memfd-secret is accessible across threads within the same process bound. unsafe impl Sync for MemfdSecretSecureKeyContainer {} impl SecureKeyContainer for MemfdSecretSecureKeyContainer { diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mlock.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mlock.rs index db21cd7fedc..961988c1d40 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mlock.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mlock.rs @@ -1,8 +1,9 @@ use std::ptr::NonNull; -use super::crypto::MemoryEncryptionKey; -use super::crypto::KEY_SIZE; -use super::SecureKeyContainer; +use super::{ + crypto::{MemoryEncryptionKey, KEY_SIZE}, + SecureKeyContainer, +}; /// A SecureKeyContainer that uses mlock to prevent the memory from being swapped to disk. /// This does not provide as strong protections as other methods, but is always supported. diff --git a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs index 6c3b53117a5..26e72f7d581 100644 --- a/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs +++ b/apps/desktop/desktop_native/core/src/secure_memory/secure_key/mod.rs @@ -1,9 +1,12 @@ -//! This module provides hardened storage for single cryptographic keys. These are meant for encrypting large amounts of memory. -//! Some platforms restrict how many keys can be protected by their APIs, which necessitates this layer of indirection. This significantly -//! reduces the complexity of each platform specific implementation, since all that's needed is implementing protecting a single fixed sized key -//! instead of protecting many arbitrarily sized secrets. This significantly lowers the effort to maintain each implementation. +//! This module provides hardened storage for single cryptographic keys. These are meant for +//! encrypting large amounts of memory. Some platforms restrict how many keys can be protected by +//! their APIs, which necessitates this layer of indirection. This significantly reduces the +//! complexity of each platform specific implementation, since all that's needed is implementing +//! protecting a single fixed sized key instead of protecting many arbitrarily sized secrets. This +//! significantly lowers the effort to maintain each implementation. //! -//! The implementations include DPAPI on Windows, `keyctl` on Linux, and `memfd_secret` on Linux, and a fallback implementation using mlock. +//! The implementations include DPAPI on Windows, `keyctl` on Linux, and `memfd_secret` on Linux, +//! and a fallback implementation using mlock. use tracing::info; @@ -20,12 +23,13 @@ pub use crypto::EncryptedMemory; use crate::secure_memory::secure_key::crypto::DecryptionError; -/// An ephemeral key that is protected using a platform mechanism. It is generated on construction freshly, and can be used -/// to encrypt and decrypt segments of memory. Since the key is ephemeral, persistent data cannot be encrypted with this key. -/// On Linux and Windows, in most cases the protection mechanisms prevent memory dumps/debuggers from reading the key. +/// An ephemeral key that is protected using a platform mechanism. It is generated on construction +/// freshly, and can be used to encrypt and decrypt segments of memory. Since the key is ephemeral, +/// persistent data cannot be encrypted with this key. On Linux and Windows, in most cases the +/// protection mechanisms prevent memory dumps/debuggers from reading the key. /// -/// Note: This can be circumvented if code can be injected into the process and is only effective in combination with the -/// memory isolation provided in `process_isolation`. +/// Note: This can be circumvented if code can be injected into the process and is only effective in +/// combination with the memory isolation provided in `process_isolation`. /// - https://github.com/zer1t0/keydump #[allow(unused)] pub(crate) struct SecureMemoryEncryptionKey(CrossPlatformSecureKeyContainer); @@ -55,7 +59,8 @@ impl SecureMemoryEncryptionKey { /// from memory attacks. #[allow(unused)] trait SecureKeyContainer: Sync + Send { - /// Returns the key as a byte slice. This slice does not have additional memory protections applied. + /// Returns the key as a byte slice. This slice does not have additional memory protections + /// applied. fn as_key(&self) -> crypto::MemoryEncryptionKey; /// Creates a new SecureKeyContainer from the provided key. fn from_key(key: crypto::MemoryEncryptionKey) -> Self; diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs index 61cb8fc187d..8ba64618ffa 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/mod.rs @@ -7,13 +7,12 @@ use std::{ }; use base64::{engine::general_purpose::STANDARD, Engine as _}; -use tokio::sync::Mutex; -use tokio_util::sync::CancellationToken; - use bitwarden_russh::{ session_bind::SessionBindResult, ssh_agent::{self, SshKey}, }; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; use tracing::{error, info}; #[cfg_attr(target_os = "windows", path = "windows.rs")] @@ -34,7 +33,8 @@ pub struct BitwardenDesktopAgent { show_ui_request_tx: tokio::sync::mpsc::Sender, get_ui_response_rx: Arc>>, request_id: Arc, - /// before first unlock, or after account switching, listing keys should require an unlock to get a list of public keys + /// before first unlock, or after account switching, listing keys should require an unlock to + /// get a list of public keys needs_unlock: Arc, is_running: Arc, } diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs b/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs index cb10e873a33..38b2193faf5 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/named_pipe_listener_stream.rs @@ -1,7 +1,6 @@ -use futures::Stream; -use std::os::windows::prelude::AsRawHandle as _; use std::{ io, + os::windows::prelude::AsRawHandle as _, pin::Pin, sync::{ atomic::{AtomicBool, Ordering}, @@ -9,6 +8,8 @@ use std::{ }, task::{Context, Poll}, }; + +use futures::Stream; use tokio::{ net::windows::named_pipe::{NamedPipeServer, ServerOptions}, select, diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs b/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs index 77eec5e35c7..5b6b1d8f36b 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/peercred_unix_listener_stream.rs @@ -1,11 +1,13 @@ +use std::{ + io, + pin::Pin, + task::{Context, Poll}, +}; + use futures::Stream; -use std::io; -use std::pin::Pin; -use std::task::{Context, Poll}; use tokio::net::{UnixListener, UnixStream}; -use super::peerinfo; -use super::peerinfo::models::PeerInfo; +use super::{peerinfo, peerinfo::models::PeerInfo}; #[derive(Debug)] pub struct PeercredUnixListenerStream { diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs b/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs index fad535cb80e..74b909f5ce7 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/peerinfo/models.rs @@ -1,9 +1,10 @@ use std::sync::{atomic::AtomicBool, Arc, Mutex}; /** -* Peerinfo represents the information of a peer process connecting over a socket. -* This can be later extended to include more information (icon, app name) for the corresponding application. -*/ + * Peerinfo represents the information of a peer process connecting over a socket. + * This can be later extended to include more information (icon, app name) for the corresponding + * application. + */ #[derive(Debug, Clone)] pub struct PeerInfo { uid: u32, diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs index a45c2f6c0bf..8623df13776 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/unix.rs @@ -6,9 +6,8 @@ use homedir::my_home; use tokio::{net::UnixListener, sync::Mutex}; use tracing::{error, info}; -use crate::ssh_agent::peercred_unix_listener_stream::PeercredUnixListenerStream; - use super::{BitwardenDesktopAgent, SshAgentUIRequest}; +use crate::ssh_agent::peercred_unix_listener_stream::PeercredUnixListenerStream; /// User can override the default socket path with this env var const ENV_BITWARDEN_SSH_AUTH_SOCK: &str = "BITWARDEN_SSH_AUTH_SOCK"; diff --git a/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs b/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs index 662a4658ede..2012dab2d77 100644 --- a/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs +++ b/apps/desktop/desktop_native/core/src/ssh_agent/windows.rs @@ -2,6 +2,7 @@ use bitwarden_russh::ssh_agent; pub mod named_pipe_listener_stream; use std::sync::Arc; + use tokio::sync::Mutex; use super::{BitwardenDesktopAgent, SshAgentUIRequest}; diff --git a/apps/desktop/desktop_native/macos_provider/Cargo.toml b/apps/desktop/desktop_native/macos_provider/Cargo.toml index ea44f3d9a27..50f1834851d 100644 --- a/apps/desktop/desktop_native/macos_provider/Cargo.toml +++ b/apps/desktop/desktop_native/macos_provider/Cargo.toml @@ -14,17 +14,16 @@ crate-type = ["staticlib", "cdylib"] bench = false [dependencies] +uniffi = { workspace = true, features = ["cli"] } + +[target.'cfg(target_os = "macos")'.dependencies] desktop_core = { path = "../core" } futures = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["sync"] } -tokio-util = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } -uniffi = { workspace = true, features = ["cli"] } - -[target.'cfg(target_os = "macos")'.dependencies] tracing-oslog = "0.3.0" [build-dependencies] diff --git a/apps/desktop/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index 3d4d6daf974..77659a93c09 100644 --- a/apps/desktop/desktop_native/napi/Cargo.toml +++ b/apps/desktop/desktop_native/napi/Cargo.toml @@ -17,17 +17,13 @@ sandbox = ["chromium_importer/sandbox"] [dependencies] anyhow = { workspace = true } autotype = { path = "../autotype" } -base64 = { workspace = true } chromium_importer = { path = "../chromium_importer" } desktop_core = { path = "../core" } -hex = { workspace = true } napi = { workspace = true, features = ["async"] } napi-derive = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true } -tokio-stream = { workspace = true } -tokio-util = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 75cb012f1c4..93fc85fb468 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -11,7 +11,10 @@ export declare namespace passwords { * Throws {@link Error} with message {@link PASSWORD_NOT_FOUND} if the password does not exist. */ export function getPassword(service: string, account: string): Promise - /** Save the password to the keychain. Adds an entry if none exists otherwise updates the existing entry. */ + /** + * Save the password to the keychain. Adds an entry if none exists otherwise updates the + * existing entry. + */ export function setPassword(service: string, account: string, password: string): Promise /** * Delete the stored password from the keychain. @@ -35,7 +38,8 @@ export declare namespace biometrics { * base64 encoded key and the base64 encoded challenge used to create it * separated by a `|` character. * - * If the iv is provided, it will be used as the challenge. Otherwise a random challenge will be generated. + * If the iv is provided, it will be used as the challenge. Otherwise a random challenge will + * be generated. * * `format!("|")` */ @@ -119,8 +123,9 @@ export declare namespace ipc { /** * Create and start the IPC server without blocking. * - * @param name The endpoint name to listen on. This name uniquely identifies the IPC connection and must be the same for both the server and client. - * @param callback This function will be called whenever a message is received from a client. + * @param name The endpoint name to listen on. This name uniquely identifies the IPC + * connection and must be the same for both the server and client. @param callback + * This function will be called whenever a message is received from a client. */ static listen(name: string, callback: (error: null | Error, message: IpcMessage) => void): Promise /** Return the path to the IPC server. */ @@ -130,8 +135,9 @@ export declare namespace ipc { /** * Send a message over the IPC server to all the connected clients * - * @return The number of clients that the message was sent to. Note that the number of messages - * actually received may be less, as some clients could disconnect before receiving the message. + * @return The number of clients that the message was sent to. Note that the number of + * messages actually received may be less, as some clients could disconnect before + * receiving the message. */ send(message: string): number } @@ -194,8 +200,9 @@ export declare namespace autofill { /** * Create and start the IPC server without blocking. * - * @param name The endpoint name to listen on. This name uniquely identifies the IPC connection and must be the same for both the server and client. - * @param callback This function will be called whenever a message is received from a client. + * @param name The endpoint name to listen on. This name uniquely identifies the IPC + * connection and must be the same for both the server and client. @param callback + * This function will be called whenever a message is received from a client. */ static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void): Promise /** Return the path to the IPC server. */ diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index f8ec4fbec25..06aaadf5f25 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -19,7 +19,8 @@ pub mod passwords { .map_err(|e| napi::Error::from_reason(e.to_string())) } - /// Save the password to the keychain. Adds an entry if none exists otherwise updates the existing entry. + /// Save the password to the keychain. Adds an entry if none exists otherwise updates the + /// existing entry. #[napi] pub async fn set_password( service: String, @@ -107,7 +108,8 @@ pub mod biometrics { /// base64 encoded key and the base64 encoded challenge used to create it /// separated by a `|` character. /// - /// If the iv is provided, it will be used as the challenge. Otherwise a random challenge will be generated. + /// If the iv is provided, it will be used as the challenge. Otherwise a random challenge will + /// be generated. /// /// `format!("|")` #[allow(clippy::unused_async)] // FIXME: Remove unused async! @@ -556,8 +558,9 @@ pub mod ipc { impl IpcServer { /// Create and start the IPC server without blocking. /// - /// @param name The endpoint name to listen on. This name uniquely identifies the IPC connection and must be the same for both the server and client. - /// @param callback This function will be called whenever a message is received from a client. + /// @param name The endpoint name to listen on. This name uniquely identifies the IPC + /// connection and must be the same for both the server and client. @param callback + /// This function will be called whenever a message is received from a client. #[allow(clippy::unused_async)] // FIXME: Remove unused async! #[napi(factory)] pub async fn listen( @@ -598,8 +601,9 @@ pub mod ipc { /// Send a message over the IPC server to all the connected clients /// - /// @return The number of clients that the message was sent to. Note that the number of messages - /// actually received may be less, as some clients could disconnect before receiving the message. + /// @return The number of clients that the message was sent to. Note that the number of + /// messages actually received may be less, as some clients could disconnect before + /// receiving the message. #[napi] pub fn send(&self, message: String) -> napi::Result { self.server @@ -743,8 +747,9 @@ pub mod autofill { impl IpcServer { /// Create and start the IPC server without blocking. /// - /// @param name The endpoint name to listen on. This name uniquely identifies the IPC connection and must be the same for both the server and client. - /// @param callback This function will be called whenever a message is received from a client. + /// @param name The endpoint name to listen on. This name uniquely identifies the IPC + /// connection and must be the same for both the server and client. @param callback + /// This function will be called whenever a message is received from a client. #[allow(clippy::unused_async)] // FIXME: Remove unused async! #[napi(factory)] pub async fn listen( @@ -946,18 +951,21 @@ pub mod logging { //! //! # Example //! - //! [Elec] 14:34:03.517 › [NAPI] [INFO] desktop_core::ssh_agent::platform_ssh_agent: Starting SSH Agent server {socket=/Users/foo/.bitwarden-ssh-agent.sock} + //! [Elec] 14:34:03.517 › [NAPI] [INFO] desktop_core::ssh_agent::platform_ssh_agent: Starting + //! SSH Agent server {socket=/Users/foo/.bitwarden-ssh-agent.sock} - use std::fmt::Write; - use std::sync::OnceLock; + use std::{fmt::Write, sync::OnceLock}; use napi::threadsafe_function::{ ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode, }; use tracing::Level; - use tracing_subscriber::fmt::format::{DefaultVisitor, Writer}; use tracing_subscriber::{ - filter::EnvFilter, layer::SubscriberExt, util::SubscriberInitExt, Layer, + filter::EnvFilter, + fmt::format::{DefaultVisitor, Writer}, + layer::SubscriberExt, + util::SubscriberInitExt, + Layer, }; struct JsLogger(OnceLock>); @@ -1069,6 +1077,8 @@ pub mod logging { #[napi] pub mod chromium_importer { + use std::collections::HashMap; + use chromium_importer::{ chromium::{ DefaultInstalledBrowserRetriever, LoginImportResult as _LoginImportResult, @@ -1076,7 +1086,6 @@ pub mod chromium_importer { }, metadata::NativeImporterMetadata as _NativeImporterMetadata, }; - use std::collections::HashMap; #[napi(object)] pub struct ProfileInfo { diff --git a/apps/desktop/desktop_native/objc/Cargo.toml b/apps/desktop/desktop_native/objc/Cargo.toml index 2cbe46ea879..ac47b47f7b3 100644 --- a/apps/desktop/desktop_native/objc/Cargo.toml +++ b/apps/desktop/desktop_native/objc/Cargo.toml @@ -8,17 +8,14 @@ publish = { workspace = true } [features] default = [] -[dependencies] +[target.'cfg(target_os = "macos")'.dependencies] anyhow = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["sync"] } tracing = { workspace = true } -[target.'cfg(target_os = "macos")'.dependencies] -core-foundation = "=0.10.1" - -[build-dependencies] -cc = "=1.2.4" +[target.'cfg(target_os = "macos")'.build-dependencies] +cc = "=1.2.46" glob = "=0.3.2" [lints] diff --git a/apps/desktop/desktop_native/process_isolation/Cargo.toml b/apps/desktop/desktop_native/process_isolation/Cargo.toml index 170832c2fde..d8c6c7a618c 100644 --- a/apps/desktop/desktop_native/process_isolation/Cargo.toml +++ b/apps/desktop/desktop_native/process_isolation/Cargo.toml @@ -8,7 +8,7 @@ publish = { workspace = true } [lib] crate-type = ["cdylib"] -[dependencies] +[target.'cfg(target_os = "linux")'.dependencies] ctor = { workspace = true } desktop_core = { path = "../core" } libc = { workspace = true } diff --git a/apps/desktop/desktop_native/process_isolation/src/lib.rs b/apps/desktop/desktop_native/process_isolation/src/lib.rs index 850ffac841e..55c5d7fafae 100644 --- a/apps/desktop/desktop_native/process_isolation/src/lib.rs +++ b/apps/desktop/desktop_native/process_isolation/src/lib.rs @@ -5,8 +5,9 @@ //! On Linux, this is PR_SET_DUMPABLE to prevent debuggers from attaching, the env //! from being read and the memory from being stolen. -use desktop_core::process_isolation; use std::{ffi::c_char, sync::LazyLock}; + +use desktop_core::process_isolation; use tracing::info; static ORIGINAL_UNSETENV: LazyLock i32> = diff --git a/apps/desktop/desktop_native/proxy/Cargo.toml b/apps/desktop/desktop_native/proxy/Cargo.toml index c672f57543d..25682fe2aa3 100644 --- a/apps/desktop/desktop_native/proxy/Cargo.toml +++ b/apps/desktop/desktop_native/proxy/Cargo.toml @@ -6,7 +6,6 @@ version = { workspace = true } publish = { workspace = true } [dependencies] -anyhow = { workspace = true } desktop_core = { path = "../core" } futures = { workspace = true } tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt"] } diff --git a/apps/desktop/desktop_native/proxy/src/main.rs b/apps/desktop/desktop_native/proxy/src/main.rs index c2c525b865a..21957d8ba32 100644 --- a/apps/desktop/desktop_native/proxy/src/main.rs +++ b/apps/desktop/desktop_native/proxy/src/main.rs @@ -60,7 +60,6 @@ fn init_logging(log_path: &Path, console_level: LevelFilter, file_level: LevelFi /// a stable communication channel between the proxy and the running desktop application. /// /// Browser extension <-[native messaging]-> proxy <-[ipc]-> desktop -/// // FIXME: Remove unwraps! They panic and terminate the whole application. #[allow(clippy::unwrap_used)] #[tokio::main(flavor = "current_thread")] @@ -83,8 +82,10 @@ async fn main() { // Different browsers send different arguments when the app starts: // // Firefox: - // - The complete path to the app manifest. (in the form `/Users//Library/.../Mozilla/NativeMessagingHosts/com.8bit.bitwarden.json`) - // - (in Firefox 55+) the ID (as given in the manifest.json) of the add-on that started it (in the form `{[UUID]}`). + // - The complete path to the app manifest. (in the form + // `/Users//Library/.../Mozilla/NativeMessagingHosts/com.8bit.bitwarden.json`) + // - (in Firefox 55+) the ID (as given in the manifest.json) of the add-on that started it (in + // the form `{[UUID]}`). // // Chrome on Windows: // - Origin of the extension that started it (in the form `chrome-extension://[ID]`). @@ -96,7 +97,8 @@ async fn main() { let args: Vec<_> = std::env::args().skip(1).collect(); info!(?args, "Process args"); - // Setup two channels, one for sending messages to the desktop application (`out`) and one for receiving messages from the desktop application (`in`) + // Setup two channels, one for sending messages to the desktop application (`out`) and one for + // receiving messages from the desktop application (`in`) let (in_send, in_recv) = tokio::sync::mpsc::channel(MESSAGE_CHANNEL_BUFFER); let (out_send, mut out_recv) = tokio::sync::mpsc::channel(MESSAGE_CHANNEL_BUFFER); diff --git a/apps/desktop/desktop_native/rustfmt.toml b/apps/desktop/desktop_native/rustfmt.toml new file mode 100644 index 00000000000..bb3baeccd76 --- /dev/null +++ b/apps/desktop/desktop_native/rustfmt.toml @@ -0,0 +1,7 @@ +# Wrap comments and increase the width of comments to 100 +comment_width = 100 +wrap_comments = true + +# Sort and group imports +group_imports = "StdExternalCrate" +imports_granularity = "Crate" diff --git a/apps/desktop/desktop_native/windows_plugin_authenticator/src/lib.rs b/apps/desktop/desktop_native/windows_plugin_authenticator/src/lib.rs index 2e4f453d8f0..893fdf765fc 100644 --- a/apps/desktop/desktop_native/windows_plugin_authenticator/src/lib.rs +++ b/apps/desktop/desktop_native/windows_plugin_authenticator/src/lib.rs @@ -2,11 +2,12 @@ #![allow(non_snake_case)] #![allow(non_camel_case_types)] -use std::ffi::c_uchar; -use std::ptr; -use windows::Win32::Foundation::*; -use windows::Win32::System::Com::*; -use windows::Win32::System::LibraryLoader::*; +use std::{ffi::c_uchar, ptr}; + +use windows::Win32::{ + Foundation::*, + System::{Com::*, LibraryLoader::*}, +}; use windows_core::*; mod pluginauthenticator; diff --git a/apps/desktop/native-messaging-test-runner/package-lock.json b/apps/desktop/native-messaging-test-runner/package-lock.json index a4286aabed9..9ad1ffb3ec0 100644 --- a/apps/desktop/native-messaging-test-runner/package-lock.json +++ b/apps/desktop/native-messaging-test-runner/package-lock.json @@ -19,7 +19,7 @@ "yargs": "18.0.0" }, "devDependencies": { - "@types/node": "22.19.0", + "@types/node": "22.19.1", "typescript": "5.4.2" } }, @@ -117,9 +117,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.0.tgz", - "integrity": "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==", + "version": "22.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", + "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "license": "MIT", "peer": true, "dependencies": { diff --git a/apps/desktop/native-messaging-test-runner/package.json b/apps/desktop/native-messaging-test-runner/package.json index 55699af47dd..21a6ba3626a 100644 --- a/apps/desktop/native-messaging-test-runner/package.json +++ b/apps/desktop/native-messaging-test-runner/package.json @@ -24,7 +24,7 @@ "yargs": "18.0.0" }, "devDependencies": { - "@types/node": "22.19.0", + "@types/node": "22.19.1", "typescript": "5.4.2" }, "_moduleAliases": { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index dbeea1558f8..2a683abe7af 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2025.11.1", + "version": "2025.11.2", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/locales/fr/messages.json b/apps/desktop/src/locales/fr/messages.json index acd5037bb6d..6cca98444b8 100644 --- a/apps/desktop/src/locales/fr/messages.json +++ b/apps/desktop/src/locales/fr/messages.json @@ -70,7 +70,7 @@ } }, "noEditPermissions": { - "message": "You don't have permission to edit this item" + "message": "Vous n'avez pas l'autorisation de modifier cet élément" }, "welcomeBack": { "message": "Content de vous revoir" @@ -4195,33 +4195,33 @@ "message": "Numéro de carte" }, "upgradeNow": { - "message": "Upgrade now" + "message": "Mettre à niveau maintenant" }, "builtInAuthenticator": { - "message": "Built-in authenticator" + "message": "Authentificateur intégré" }, "secureFileStorage": { - "message": "Secure file storage" + "message": "Stockage sécurisé de fichier" }, "emergencyAccess": { - "message": "Emergency access" + "message": "Accès d'urgence" }, "breachMonitoring": { - "message": "Breach monitoring" + "message": "Surveillance des fuites" }, "andMoreFeatures": { - "message": "And more!" + "message": "Et encore plus !" }, "planDescPremium": { - "message": "Complete online security" + "message": "Sécurité en ligne complète" }, "upgradeToPremium": { - "message": "Upgrade to Premium" + "message": "Mettre à niveau vers Premium" }, "sessionTimeoutSettingsAction": { - "message": "Timeout action" + "message": "Action à l’expiration" }, "sessionTimeoutHeader": { - "message": "Session timeout" + "message": "Délai d'expiration de la session" } } diff --git a/apps/desktop/src/locales/he/messages.json b/apps/desktop/src/locales/he/messages.json index 87fac938a34..868cd9ccbc5 100644 --- a/apps/desktop/src/locales/he/messages.json +++ b/apps/desktop/src/locales/he/messages.json @@ -4195,33 +4195,33 @@ "message": "מספר כרטיס" }, "upgradeNow": { - "message": "Upgrade now" + "message": "שדרג עכשיו" }, "builtInAuthenticator": { - "message": "Built-in authenticator" + "message": "מאמת מובנה" }, "secureFileStorage": { - "message": "Secure file storage" + "message": "אחסון קבצים מאובטח" }, "emergencyAccess": { - "message": "Emergency access" + "message": "גישת חירום" }, "breachMonitoring": { - "message": "Breach monitoring" + "message": "ניטור פרצות" }, "andMoreFeatures": { - "message": "And more!" + "message": "ועוד!" }, "planDescPremium": { - "message": "Complete online security" + "message": "השלם אבטחה מקוונת" }, "upgradeToPremium": { - "message": "Upgrade to Premium" + "message": "שדרג לפרימיום" }, "sessionTimeoutSettingsAction": { - "message": "Timeout action" + "message": "פעולת פסק זמן" }, "sessionTimeoutHeader": { - "message": "Session timeout" + "message": "פסק זמן להפעלה" } } diff --git a/apps/desktop/src/locales/sk/messages.json b/apps/desktop/src/locales/sk/messages.json index e5763d78b9c..0b14b961bbb 100644 --- a/apps/desktop/src/locales/sk/messages.json +++ b/apps/desktop/src/locales/sk/messages.json @@ -1692,7 +1692,7 @@ "description": "Default URI match detection for auto-fill." }, "toggleOptions": { - "message": "Voľby prepínača" + "message": "Zobraziť/skryť možnosti" }, "organization": { "message": "Organizácia", @@ -2425,7 +2425,7 @@ "message": "Hlavné heslo bolo úspešne nastavené" }, "updatedMasterPassword": { - "message": "Hlavné heslo aktualizované" + "message": "Hlavné heslo bolo aktualizované" }, "updateMasterPassword": { "message": "Aktualizovať hlavné heslo" @@ -2476,7 +2476,7 @@ "message": "Použiť PIN kód" }, "useBiometrics": { - "message": "Použiť biometrické údaje" + "message": "Použiť biometriu" }, "enterVerificationCodeSentToEmail": { "message": "Zadajte overovací kód, ktorý vám bol zaslaný na e-mail." @@ -2574,7 +2574,7 @@ "message": "Táto organizácia má podnikovú politiku, ktorá vás automaticky zaregistruje na obnovenie hesla. Registrácia umožní správcom organizácie zmeniť vaše hlavné heslo." }, "vaultExportDisabled": { - "message": "Export trezoru je zakázaný" + "message": "Export trezoru bol odstránený" }, "personalVaultExportPolicyInEffect": { "message": "Jedna alebo viacero zásad organizácie vám bráni exportovať váš osobný trezor." @@ -2619,7 +2619,7 @@ "message": "Predvoľby" }, "appPreferences": { - "message": "Nastavenia aplikácie (Všetky účty)" + "message": "Nastavenia aplikácie (všetky účty)" }, "accountSwitcherLimitReached": { "message": "Dosiahnutý limit počtu účtov. Odhláste sa z účtu aby ste mohli pridať ďalší." @@ -2790,7 +2790,7 @@ "message": "Použiť možnosti subadresovania svojho poskytovateľa e-mailu." }, "catchallEmail": { - "message": "Catch-all Email" + "message": "E-mail Catch-all" }, "catchallEmailDesc": { "message": "Použiť doručenú poštu typu catch-all nastavenú na doméne." @@ -2843,7 +2843,7 @@ "description": "Guidance provided for email forwarding services that support multiple email domains." }, "forwarderError": { - "message": "$SERVICENAME$ chyba: $ERRORMESSAGE$", + "message": "Chyba $SERVICENAME$: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", "placeholders": { "servicename": { @@ -2959,7 +2959,7 @@ } }, "forwarderUnknownForwarder": { - "message": "Nepodporovaná služba: '$SERVICENAME$'.", + "message": "Neznáme presmerovanie: '$SERVICENAME$'.", "description": "Displayed when the forwarding service is not supported.", "placeholders": { "servicename": { @@ -2976,7 +2976,7 @@ "message": "Prístupový token API" }, "apiKey": { - "message": "API kľúč" + "message": "Kľúč API" }, "premiumSubcriptionRequired": { "message": "Vyžaduje sa predplatné Prémium" diff --git a/apps/desktop/src/locales/zh_CN/messages.json b/apps/desktop/src/locales/zh_CN/messages.json index 353fc036f63..b5e68b83bde 100644 --- a/apps/desktop/src/locales/zh_CN/messages.json +++ b/apps/desktop/src/locales/zh_CN/messages.json @@ -1503,7 +1503,7 @@ "message": "优先客户支持。" }, "premiumSignUpFuture": { - "message": "所有未来的高级功能。即将推出!" + "message": "未来的更多高级版功能。敬请期待!" }, "premiumPurchase": { "message": "购买高级版" @@ -2029,7 +2029,7 @@ "message": "Bitwarden 可以存储并填充两步验证码。选择相机图标来拍摄此网站的验证器二维码,或将密钥复制并粘贴到此字段。" }, "premium": { - "message": "高级会员", + "message": "高级版", "description": "Premium membership" }, "freeOrgsCannotUseAttachments": { diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index c11c8f08cd0..0a4204bf233 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2025.11.1", + "version": "2025.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2025.11.1", + "version": "2025.11.2", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi" diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index d4800009de9..765bc771b9e 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2025.11.1", + "version": "2025.11.2", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.html b/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.html index 9ddc9897a31..a8021e82c39 100644 --- a/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.html +++ b/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.html @@ -1 +1 @@ - + diff --git a/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.ts b/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.ts index 8a58f5b92d7..3c1d0d2b691 100644 --- a/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/group-badge/group-name-badge.component.ts @@ -1,36 +1,33 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Input, OnChanges } from "@angular/core"; +import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core"; import { SelectionReadOnlyRequest } from "@bitwarden/common/admin-console/models/request/selection-read-only.request"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { GroupView } from "../../core"; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-group-badge", templateUrl: "group-name-badge.component.html", standalone: false, + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class GroupNameBadgeComponent implements OnChanges { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() selectedGroups: SelectionReadOnlyRequest[]; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() allGroups: GroupView[]; +export class GroupNameBadgeComponent { + readonly selectedGroups = input([]); + readonly allGroups = input([]); - protected groupNames: string[] = []; + protected readonly groupNames = computed(() => { + const allGroups = this.allGroups(); + if (!allGroups) { + return []; + } + + return this.selectedGroups() + .map((g) => { + return allGroups.find((o) => o.id === g.id)?.name; + }) + .filter((name): name is string => name !== undefined) + .sort(this.i18nService.collator.compare); + }); constructor(private i18nService: I18nService) {} - - ngOnChanges() { - this.groupNames = this.selectedGroups - .map((g) => { - return this.allGroups.find((o) => o.id === g.id)?.name; - }) - .sort(this.i18nService.collator.compare); - } } diff --git a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html index 0abc40da683..bd2237bc2fd 100644 --- a/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html +++ b/apps/web/src/app/admin-console/organizations/policies/policy-edit-definitions/vnext-organization-data-ownership.component.html @@ -1,5 +1,5 @@

- {{ "organizationDataOwnershipContent" | i18n }} + {{ "organizationDataOwnershipDescContent" | i18n }} (); + async ngOnInit(): Promise { const milestone3FeatureEnabled = await this.configService.getFeatureFlag( FeatureFlag.PM26462_Milestone_3, @@ -37,7 +39,7 @@ export class CreateOrganizationComponent implements OnInit { ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025; - this.route.queryParams.pipe(first(), takeUntilDestroyed()).subscribe((qParams) => { + this.route.queryParams.pipe(first(), takeUntil(this.destroy$)).subscribe((qParams) => { if (qParams.plan === "families" || qParams.productTier == ProductTierType.Families) { this.plan = familyPlan; this.productTier = ProductTierType.Families; @@ -61,4 +63,9 @@ export class CreateOrganizationComponent implements OnInit { this.secretsManager = qParams.product == ProductType.SecretsManager; }); } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html index 172646f5d4d..8baf304969f 100644 --- a/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html @@ -25,23 +25,21 @@

  • {{ "twoFactorYubikeySaveForm" | i18n }}

  • -
    -
    -
    - {{ "yubikeyX" | i18n: (i + 1).toString() }} - - - -
    - {{ keys[i].existingKey }} - -
    +
    +
    + {{ "yubikeyX" | i18n: (i + 1).toString() }} + + + +
    + {{ keys[i].existingKey }} +
    diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 70e16ad3037..e0c1a12a80f 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -344,6 +344,7 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy data: { type: "Organization", id: this.organizationId, + plan: this.sub.plan.type, }, }); diff --git a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts index 5448f03aa56..9e7b870579d 100644 --- a/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts +++ b/apps/web/src/app/billing/payment/components/enter-payment-method.component.ts @@ -1,9 +1,10 @@ -import { Component, Input, OnInit } from "@angular/core"; +import { ChangeDetectionStrategy, Component, input, OnDestroy, OnInit } from "@angular/core"; import { FormControl, FormGroup, Validators } from "@angular/forms"; import { map, Observable, of, startWith, Subject, takeUntil } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { PopoverModule, ToastService } from "@bitwarden/components"; import { SharedModule } from "../../../shared"; @@ -34,18 +35,17 @@ type PaymentMethodFormGroup = FormGroup<{ }>; }>; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "app-enter-payment-method", + changeDetection: ChangeDetectionStrategy.OnPush, template: ` - @let showBillingDetails = includeBillingAddress && selected !== "payPal"; -
    + @let showBillingDetails = includeBillingAddress() && selected !== "payPal"; + @if (showBillingDetails) {
    {{ "paymentMethod" | i18n }}
    }
    - + @@ -60,7 +60,7 @@ type PaymentMethodFormGroup = FormGroup<{ } - @if (showPayPal) { + @if (showPayPal()) { @@ -68,7 +68,7 @@ type PaymentMethodFormGroup = FormGroup<{ } - @if (showAccountCredit) { + @if (showAccountCredit()) { @@ -82,10 +82,10 @@ type PaymentMethodFormGroup = FormGroup<{ @case ("card") {
    - + {{ "cardNumberLabel" | i18n }} -
    +
    - + {{ "expiration" | i18n }} -
    +
    - + {{ "securityCodeSlashCVV" | i18n }}
    } @@ -131,7 +131,7 @@ type PaymentMethodFormGroup = FormGroup<{ bitInput id="routingNumber" type="text" - [formControl]="group.controls.bankAccount.controls.routingNumber" + [formControl]="group().controls.bankAccount.controls.routingNumber" required /> @@ -141,7 +141,7 @@ type PaymentMethodFormGroup = FormGroup<{ bitInput id="accountNumber" type="text" - [formControl]="group.controls.bankAccount.controls.accountNumber" + [formControl]="group().controls.bankAccount.controls.accountNumber" required /> @@ -151,7 +151,7 @@ type PaymentMethodFormGroup = FormGroup<{ id="accountHolderName" bitInput type="text" - [formControl]="group.controls.bankAccount.controls.accountHolderName" + [formControl]="group().controls.bankAccount.controls.accountHolderName" required /> @@ -159,7 +159,7 @@ type PaymentMethodFormGroup = FormGroup<{ {{ "bankAccountType" | i18n }} @@ -186,7 +186,7 @@ type PaymentMethodFormGroup = FormGroup<{ } @case ("accountCredit") { - @if (hasEnoughAccountCredit) { + @if (hasEnoughAccountCredit()) { {{ "makeSureEnoughCredit" | i18n }} @@ -204,7 +204,7 @@ type PaymentMethodFormGroup = FormGroup<{
    {{ "country" | i18n }} - + @for (selectableCountry of selectableCountries; track selectableCountry.value) { @@ -233,26 +233,15 @@ type PaymentMethodFormGroup = FormGroup<{ standalone: true, imports: [BillingServicesModule, PaymentLabelComponent, PopoverModule, SharedModule], }) -export class EnterPaymentMethodComponent implements OnInit { - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input({ required: true }) group!: PaymentMethodFormGroup; +export class EnterPaymentMethodComponent implements OnInit, OnDestroy { + protected readonly instanceId = Utils.newGuid(); - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() private showBankAccount = true; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() showPayPal = true; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() showAccountCredit = false; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() hasEnoughAccountCredit = true; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() includeBillingAddress = false; + readonly group = input.required(); + protected readonly showBankAccount = input(true); + readonly showPayPal = input(true); + readonly showAccountCredit = input(false); + readonly hasEnoughAccountCredit = input(true); + readonly includeBillingAddress = input(false); protected showBankAccount$!: Observable; protected selectableCountries = selectableCountries; @@ -269,57 +258,62 @@ export class EnterPaymentMethodComponent implements OnInit { ngOnInit() { this.stripeService.loadStripe( + this.instanceId, { - cardNumber: "#stripe-card-number", - cardExpiry: "#stripe-card-expiry", - cardCvc: "#stripe-card-cvc", + cardNumber: `#stripe-card-number-${this.instanceId}`, + cardExpiry: `#stripe-card-expiry-${this.instanceId}`, + cardCvc: `#stripe-card-cvc-${this.instanceId}`, }, true, ); - if (this.showPayPal) { + if (this.showPayPal()) { this.braintreeService.loadBraintree("#braintree-container", false); } - if (!this.includeBillingAddress) { - this.showBankAccount$ = of(this.showBankAccount); - this.group.controls.billingAddress.disable(); + if (!this.includeBillingAddress()) { + this.showBankAccount$ = of(this.showBankAccount()); + this.group().controls.billingAddress.disable(); } else { - this.group.controls.billingAddress.patchValue({ + this.group().controls.billingAddress.patchValue({ country: "US", }); - this.showBankAccount$ = this.group.controls.billingAddress.controls.country.valueChanges.pipe( - startWith(this.group.controls.billingAddress.controls.country.value), - map((country) => this.showBankAccount && country === "US"), - ); + this.showBankAccount$ = + this.group().controls.billingAddress.controls.country.valueChanges.pipe( + startWith(this.group().controls.billingAddress.controls.country.value), + map((country) => this.showBankAccount() && country === "US"), + ); } - this.group.controls.type.valueChanges - .pipe(startWith(this.group.controls.type.value), takeUntil(this.destroy$)) + this.group() + .controls.type.valueChanges.pipe( + startWith(this.group().controls.type.value), + takeUntil(this.destroy$), + ) .subscribe((selected) => { if (selected === "bankAccount") { - this.group.controls.bankAccount.enable(); - if (this.includeBillingAddress) { - this.group.controls.billingAddress.enable(); + this.group().controls.bankAccount.enable(); + if (this.includeBillingAddress()) { + this.group().controls.billingAddress.enable(); } } else { switch (selected) { case "card": { - this.stripeService.mountElements(); - if (this.includeBillingAddress) { - this.group.controls.billingAddress.enable(); + this.stripeService.mountElements(this.instanceId); + if (this.includeBillingAddress()) { + this.group().controls.billingAddress.enable(); } break; } case "payPal": { this.braintreeService.createDropin(); - if (this.includeBillingAddress) { - this.group.controls.billingAddress.disable(); + if (this.includeBillingAddress()) { + this.group().controls.billingAddress.disable(); } break; } } - this.group.controls.bankAccount.disable(); + this.group().controls.bankAccount.disable(); } }); @@ -330,22 +324,28 @@ export class EnterPaymentMethodComponent implements OnInit { }); } + ngOnDestroy() { + this.stripeService.unloadStripe(this.instanceId); + this.destroy$.next(); + this.destroy$.complete(); + } + select = (paymentMethod: PaymentMethodOption) => - this.group.controls.type.patchValue(paymentMethod); + this.group().controls.type.patchValue(paymentMethod); tokenize = async (): Promise => { const exchange = async (paymentMethod: TokenizablePaymentMethod) => { switch (paymentMethod) { case "bankAccount": { - this.group.controls.bankAccount.markAllAsTouched(); - if (!this.group.controls.bankAccount.valid) { + this.group().controls.bankAccount.markAllAsTouched(); + if (!this.group().controls.bankAccount.valid) { throw new Error("Attempted to tokenize invalid bank account information."); } - const bankAccount = this.group.controls.bankAccount.getRawValue(); + const bankAccount = this.group().controls.bankAccount.getRawValue(); const clientSecret = await this.stripeService.createSetupIntent("bankAccount"); - const billingDetails = this.group.controls.billingAddress.enabled - ? this.group.controls.billingAddress.getRawValue() + const billingDetails = this.group().controls.billingAddress.enabled + ? this.group().controls.billingAddress.getRawValue() : undefined; return await this.stripeService.setupBankAccountPaymentMethod( clientSecret, @@ -355,10 +355,14 @@ export class EnterPaymentMethodComponent implements OnInit { } case "card": { const clientSecret = await this.stripeService.createSetupIntent("card"); - const billingDetails = this.group.controls.billingAddress.enabled - ? this.group.controls.billingAddress.getRawValue() + const billingDetails = this.group().controls.billingAddress.enabled + ? this.group().controls.billingAddress.getRawValue() : undefined; - return this.stripeService.setupCardPaymentMethod(clientSecret, billingDetails); + return this.stripeService.setupCardPaymentMethod( + this.instanceId, + clientSecret, + billingDetails, + ); } case "payPal": { return this.braintreeService.requestPaymentMethod(); @@ -410,15 +414,15 @@ export class EnterPaymentMethodComponent implements OnInit { validate = (): boolean => { if (this.selected === "bankAccount") { - this.group.controls.bankAccount.markAllAsTouched(); - return this.group.controls.bankAccount.valid; + this.group().controls.bankAccount.markAllAsTouched(); + return this.group().controls.bankAccount.valid; } return true; }; get selected(): PaymentMethodOption { - return this.group.value.type!; + return this.group().value.type!; } static getFormGroup = (): PaymentMethodFormGroup => diff --git a/apps/web/src/app/billing/services/stripe.service.spec.ts b/apps/web/src/app/billing/services/stripe.service.spec.ts new file mode 100644 index 00000000000..983aeb266ae --- /dev/null +++ b/apps/web/src/app/billing/services/stripe.service.spec.ts @@ -0,0 +1,797 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { BankAccount } from "@bitwarden/common/billing/models/domain"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; + +import { StripeService } from "./stripe.service"; + +// Extend Window interface to include Stripe +declare global { + interface Window { + Stripe: any; + } +} + +describe("StripeService", () => { + let service: StripeService; + let apiService: MockProxy; + let logService: MockProxy; + + // Stripe SDK mocks + let mockStripeInstance: any; + let mockElements: any; + let mockCardNumber: any; + let mockCardExpiry: any; + let mockCardCvc: any; + + // DOM mocks + let mockScript: HTMLScriptElement; + let mockIframe: HTMLIFrameElement; + + beforeEach(() => { + jest.useFakeTimers(); + + // Setup service dependency mocks + apiService = mock(); + logService = mock(); + + // Setup Stripe element mocks + mockCardNumber = { + mount: jest.fn(), + unmount: jest.fn(), + }; + mockCardExpiry = { + mount: jest.fn(), + unmount: jest.fn(), + }; + mockCardCvc = { + mount: jest.fn(), + unmount: jest.fn(), + }; + + // Setup Stripe Elements mock + mockElements = { + create: jest.fn((type: string) => { + switch (type) { + case "cardNumber": + return mockCardNumber; + case "cardExpiry": + return mockCardExpiry; + case "cardCvc": + return mockCardCvc; + default: + return null; + } + }), + getElement: jest.fn((type: string) => { + switch (type) { + case "cardNumber": + return mockCardNumber; + case "cardExpiry": + return mockCardExpiry; + case "cardCvc": + return mockCardCvc; + default: + return null; + } + }), + }; + + // Setup Stripe instance mock + mockStripeInstance = { + elements: jest.fn(() => mockElements), + confirmCardSetup: jest.fn(), + confirmUsBankAccountSetup: jest.fn(), + }; + + // Setup window.Stripe mock + window.Stripe = jest.fn(() => mockStripeInstance); + + // Setup DOM mocks + mockScript = { + id: "", + src: "", + onload: null, + onerror: null, + } as any; + + mockIframe = { + src: "https://js.stripe.com/v3/", + remove: jest.fn(), + } as any; + + jest.spyOn(window.document, "createElement").mockReturnValue(mockScript); + jest.spyOn(window.document, "getElementById").mockReturnValue(null); + jest.spyOn(window.document.head, "appendChild").mockReturnValue(mockScript); + jest.spyOn(window.document.head, "removeChild").mockImplementation(() => mockScript); + jest.spyOn(window.document, "querySelectorAll").mockReturnValue([mockIframe] as any); + + // Mock getComputedStyle + jest.spyOn(window, "getComputedStyle").mockReturnValue({ + getPropertyValue: (prop: string) => { + const props: Record = { + "--color-text-main": "0, 0, 0", + "--color-text-muted": "128, 128, 128", + "--color-danger-600": "220, 38, 38", + }; + return props[prop] || ""; + }, + } as any); + + // Create service instance + service = new StripeService(apiService, logService); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + // Helper function to trigger script load + const triggerScriptLoad = () => { + if (mockScript.onload) { + mockScript.onload(new Event("load")); + } + }; + + // Helper function to advance timers and flush promises + const advanceTimersAndFlush = async (ms: number) => { + jest.advanceTimersByTime(ms); + await Promise.resolve(); + }; + + describe("createSetupIntent", () => { + it("should call API with correct path for card payment", async () => { + apiService.send.mockResolvedValue("client_secret_card_123"); + + const result = await service.createSetupIntent("card"); + + expect(apiService.send).toHaveBeenCalledWith("POST", "/setup-intent/card", null, true, true); + expect(result).toBe("client_secret_card_123"); + }); + + it("should call API with correct path for bank account payment", async () => { + apiService.send.mockResolvedValue("client_secret_bank_456"); + + const result = await service.createSetupIntent("bankAccount"); + + expect(apiService.send).toHaveBeenCalledWith( + "POST", + "/setup-intent/bank-account", + null, + true, + true, + ); + expect(result).toBe("client_secret_bank_456"); + }); + + it("should return client secret from API response", async () => { + const expectedSecret = "seti_1234567890_secret_abcdefg"; + apiService.send.mockResolvedValue(expectedSecret); + + const result = await service.createSetupIntent("card"); + + expect(result).toBe(expectedSecret); + }); + + it("should propagate API errors", async () => { + const error = new Error("API error"); + apiService.send.mockRejectedValue(error); + + await expect(service.createSetupIntent("card")).rejects.toThrow("API error"); + }); + }); + + describe("loadStripe - initial load", () => { + const instanceId = "test-instance-1"; + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + it("should create script element with correct attributes", () => { + service.loadStripe(instanceId, elementIds, false); + + expect(window.document.createElement).toHaveBeenCalledWith("script"); + expect(mockScript.id).toBe("stripe-script"); + expect(mockScript.src).toBe("https://js.stripe.com/v3?advancedFraudSignals=false"); + }); + + it("should append script to document head", () => { + service.loadStripe(instanceId, elementIds, false); + + expect(window.document.head.appendChild).toHaveBeenCalledWith(mockScript); + }); + + it("should initialize Stripe client on script load", async () => { + service.loadStripe(instanceId, elementIds, false); + + triggerScriptLoad(); + await advanceTimersAndFlush(0); + + expect(window.Stripe).toHaveBeenCalledWith(process.env.STRIPE_KEY); + }); + + it("should create Elements instance and store in Map", async () => { + service.loadStripe(instanceId, elementIds, false); + + triggerScriptLoad(); + await advanceTimersAndFlush(50); + + expect(mockStripeInstance.elements).toHaveBeenCalled(); + expect(service["instances"].size).toBe(1); + expect(service["instances"].get(instanceId)).toBeDefined(); + }); + + it("should increment instanceCount", async () => { + service.loadStripe(instanceId, elementIds, false); + + triggerScriptLoad(); + await advanceTimersAndFlush(50); + + expect(service["instanceCount"]).toBe(1); + }); + }); + + describe("loadStripe - already loaded", () => { + const instanceId1 = "instance-1"; + const instanceId2 = "instance-2"; + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + beforeEach(async () => { + // Load first instance to initialize Stripe + service.loadStripe(instanceId1, elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + }); + + it("should not create new script if already loaded", () => { + jest.clearAllMocks(); + + service.loadStripe(instanceId2, elementIds, false); + + expect(window.document.createElement).not.toHaveBeenCalled(); + expect(window.document.head.appendChild).not.toHaveBeenCalled(); + }); + + it("should immediately initialize instance when script loaded", async () => { + service.loadStripe(instanceId2, elementIds, false); + await advanceTimersAndFlush(50); + + expect(service["instances"].size).toBe(2); + expect(service["instances"].get(instanceId2)).toBeDefined(); + }); + + it("should increment instanceCount correctly", async () => { + expect(service["instanceCount"]).toBe(1); + + service.loadStripe(instanceId2, elementIds, false); + await advanceTimersAndFlush(50); + + expect(service["instanceCount"]).toBe(2); + }); + }); + + describe("loadStripe - concurrent calls", () => { + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + it("should handle multiple loadStripe calls sequentially", async () => { + // Test practical scenario: load instances one after another + service.loadStripe("instance-1", elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + + service.loadStripe("instance-2", elementIds, false); + await advanceTimersAndFlush(100); + + service.loadStripe("instance-3", elementIds, false); + await advanceTimersAndFlush(100); + + // All instances should be initialized + expect(service["instances"].size).toBe(3); + expect(service["instanceCount"]).toBe(3); + expect(service["instances"].get("instance-1")).toBeDefined(); + expect(service["instances"].get("instance-2")).toBeDefined(); + expect(service["instances"].get("instance-3")).toBeDefined(); + }); + + it("should share Stripe client across instances", async () => { + // Load first instance + service.loadStripe("instance-1", elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + + const stripeClientAfterFirst = service["stripe"]; + expect(stripeClientAfterFirst).toBeDefined(); + + // Load second instance + service.loadStripe("instance-2", elementIds, false); + await advanceTimersAndFlush(100); + + // Should reuse the same Stripe client + expect(service["stripe"]).toBe(stripeClientAfterFirst); + expect(service["instances"].size).toBe(2); + }); + }); + + describe("mountElements - success path", () => { + const instanceId = "mount-test-instance"; + const elementIds = { + cardNumber: "#card-number-mount", + cardExpiry: "#card-expiry-mount", + cardCvc: "#card-cvc-mount", + }; + + beforeEach(async () => { + service.loadStripe(instanceId, elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + }); + + it("should mount all three card elements to DOM", async () => { + service.mountElements(instanceId); + await advanceTimersAndFlush(100); + + expect(mockCardNumber.mount).toHaveBeenCalledWith("#card-number-mount"); + expect(mockCardExpiry.mount).toHaveBeenCalledWith("#card-expiry-mount"); + expect(mockCardCvc.mount).toHaveBeenCalledWith("#card-cvc-mount"); + }); + + it("should use correct element IDs from instance", async () => { + const customIds = { + cardNumber: "#custom-card", + cardExpiry: "#custom-expiry", + cardCvc: "#custom-cvc", + }; + + service.loadStripe("custom-instance", customIds, false); + await advanceTimersAndFlush(100); + + service.mountElements("custom-instance"); + await advanceTimersAndFlush(100); + + expect(mockCardNumber.mount).toHaveBeenCalledWith("#custom-card"); + expect(mockCardExpiry.mount).toHaveBeenCalledWith("#custom-expiry"); + expect(mockCardCvc.mount).toHaveBeenCalledWith("#custom-cvc"); + }); + + it("should handle autoMount flag correctly", async () => { + const autoMountId = "auto-mount-instance"; + jest.clearAllMocks(); + + service.loadStripe(autoMountId, elementIds, true); + triggerScriptLoad(); + await advanceTimersAndFlush(150); + + // Should auto-mount without explicit call + expect(mockCardNumber.mount).toHaveBeenCalled(); + expect(mockCardExpiry.mount).toHaveBeenCalled(); + expect(mockCardCvc.mount).toHaveBeenCalled(); + }); + }); + + describe("mountElements - retry logic", () => { + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + it("should retry if instance not found", async () => { + service.mountElements("non-existent-instance"); + await advanceTimersAndFlush(100); + + expect(logService.warning).toHaveBeenCalledWith( + expect.stringContaining("Stripe instance non-existent-instance not found"), + ); + }); + + it("should log error after 10 failed attempts", async () => { + service.mountElements("non-existent-instance"); + + for (let i = 0; i < 10; i++) { + await advanceTimersAndFlush(100); + } + + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("not found after 10 attempts"), + ); + }); + + it("should retry if elements not ready", async () => { + const instanceId = "retry-elements-instance"; + service.loadStripe(instanceId, elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + + // Make elements temporarily unavailable + mockElements.getElement.mockReturnValueOnce(null); + mockElements.getElement.mockReturnValueOnce(null); + mockElements.getElement.mockReturnValueOnce(null); + + service.mountElements(instanceId); + await advanceTimersAndFlush(100); + + expect(logService.warning).toHaveBeenCalledWith( + expect.stringContaining("Some Stripe card elements"), + ); + }); + }); + + describe("setupCardPaymentMethod", () => { + const instanceId = "card-setup-instance"; + const clientSecret = "seti_card_secret_123"; + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + beforeEach(async () => { + service.loadStripe(instanceId, elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + }); + + it("should call Stripe confirmCardSetup with correct parameters", async () => { + mockStripeInstance.confirmCardSetup.mockResolvedValue({ + setupIntent: { status: "succeeded", payment_method: "pm_card_123" }, + }); + + await service.setupCardPaymentMethod(instanceId, clientSecret); + + expect(mockStripeInstance.confirmCardSetup).toHaveBeenCalledWith(clientSecret, { + payment_method: { + card: mockCardNumber, + }, + }); + }); + + it("should include billing details when provided", async () => { + mockStripeInstance.confirmCardSetup.mockResolvedValue({ + setupIntent: { status: "succeeded", payment_method: "pm_card_123" }, + }); + + const billingDetails = { country: "US", postalCode: "12345" }; + await service.setupCardPaymentMethod(instanceId, clientSecret, billingDetails); + + expect(mockStripeInstance.confirmCardSetup).toHaveBeenCalledWith(clientSecret, { + payment_method: { + card: mockCardNumber, + billing_details: { + address: { + country: "US", + postal_code: "12345", + }, + }, + }, + }); + }); + + it("should throw error if instance not found", async () => { + await expect(service.setupCardPaymentMethod("non-existent", clientSecret)).rejects.toThrow( + "Payment method initialization failed. Please try again.", + ); + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Stripe instance non-existent not found"), + ); + }); + + it("should throw error if setup fails", async () => { + const error = { message: "Card declined" }; + mockStripeInstance.confirmCardSetup.mockResolvedValue({ error }); + + await expect(service.setupCardPaymentMethod(instanceId, clientSecret)).rejects.toEqual(error); + expect(logService.error).toHaveBeenCalledWith(error); + }); + + it("should throw error if status is not succeeded", async () => { + const error = { message: "Invalid status" }; + mockStripeInstance.confirmCardSetup.mockResolvedValue({ + setupIntent: { status: "requires_action" }, + error, + }); + + await expect(service.setupCardPaymentMethod(instanceId, clientSecret)).rejects.toEqual(error); + }); + + it("should return payment method ID on success", async () => { + mockStripeInstance.confirmCardSetup.mockResolvedValue({ + setupIntent: { status: "succeeded", payment_method: "pm_card_success_123" }, + }); + + const result = await service.setupCardPaymentMethod(instanceId, clientSecret); + + expect(result).toBe("pm_card_success_123"); + }); + }); + + describe("setupBankAccountPaymentMethod", () => { + const clientSecret = "seti_bank_secret_456"; + const bankAccount: BankAccount = { + accountHolderName: "John Doe", + routingNumber: "110000000", + accountNumber: "000123456789", + accountHolderType: "individual", + }; + + beforeEach(async () => { + // Initialize Stripe instance for bank account tests + service.loadStripe( + "bank-test-instance", + { + cardNumber: "#card", + cardExpiry: "#expiry", + cardCvc: "#cvc", + }, + false, + ); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + }); + + it("should call Stripe confirmUsBankAccountSetup with bank details", async () => { + mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({ + setupIntent: { status: "requires_action", payment_method: "pm_bank_123" }, + }); + + await service.setupBankAccountPaymentMethod(clientSecret, bankAccount); + + expect(mockStripeInstance.confirmUsBankAccountSetup).toHaveBeenCalledWith(clientSecret, { + payment_method: { + us_bank_account: { + routing_number: "110000000", + account_number: "000123456789", + account_holder_type: "individual", + }, + billing_details: { + name: "John Doe", + }, + }, + }); + }); + + it("should include billing address when provided", async () => { + mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({ + setupIntent: { status: "requires_action", payment_method: "pm_bank_123" }, + }); + + const billingDetails = { country: "US", postalCode: "90210" }; + await service.setupBankAccountPaymentMethod(clientSecret, bankAccount, billingDetails); + + expect(mockStripeInstance.confirmUsBankAccountSetup).toHaveBeenCalledWith(clientSecret, { + payment_method: { + us_bank_account: { + routing_number: "110000000", + account_number: "000123456789", + account_holder_type: "individual", + }, + billing_details: { + name: "John Doe", + address: { + country: "US", + postal_code: "90210", + }, + }, + }, + }); + }); + + it("should omit billing address when not provided", async () => { + mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({ + setupIntent: { status: "requires_action", payment_method: "pm_bank_123" }, + }); + + await service.setupBankAccountPaymentMethod(clientSecret, bankAccount); + + const call = mockStripeInstance.confirmUsBankAccountSetup.mock.calls[0][1]; + expect(call.payment_method.billing_details.address).toBeUndefined(); + }); + + it("should validate status is requires_action", async () => { + const error = { message: "Invalid status" }; + mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({ + setupIntent: { status: "succeeded" }, + error, + }); + + await expect( + service.setupBankAccountPaymentMethod(clientSecret, bankAccount), + ).rejects.toEqual(error); + }); + + it("should return payment method ID on success", async () => { + mockStripeInstance.confirmUsBankAccountSetup.mockResolvedValue({ + setupIntent: { status: "requires_action", payment_method: "pm_bank_success_456" }, + }); + + const result = await service.setupBankAccountPaymentMethod(clientSecret, bankAccount); + + expect(result).toBe("pm_bank_success_456"); + }); + }); + + describe("unloadStripe - single instance", () => { + const instanceId = "unload-test-instance"; + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + beforeEach(async () => { + service.loadStripe(instanceId, elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + }); + + it("should unmount all card elements", () => { + service.unloadStripe(instanceId); + + expect(mockCardNumber.unmount).toHaveBeenCalled(); + expect(mockCardExpiry.unmount).toHaveBeenCalled(); + expect(mockCardCvc.unmount).toHaveBeenCalled(); + }); + + it("should remove instance from Map", () => { + expect(service["instances"].has(instanceId)).toBe(true); + + service.unloadStripe(instanceId); + + expect(service["instances"].has(instanceId)).toBe(false); + }); + + it("should decrement instanceCount", () => { + expect(service["instanceCount"]).toBe(1); + + service.unloadStripe(instanceId); + + expect(service["instanceCount"]).toBe(0); + }); + + it("should remove script when last instance unloaded", () => { + jest.spyOn(window.document, "getElementById").mockReturnValue(mockScript); + + service.unloadStripe(instanceId); + + expect(window.document.head.removeChild).toHaveBeenCalledWith(mockScript); + }); + + it("should remove Stripe iframes after cleanup delay", async () => { + service.unloadStripe(instanceId); + + await advanceTimersAndFlush(500); + + expect(window.document.querySelectorAll).toHaveBeenCalledWith("iframe"); + expect(mockIframe.remove).toHaveBeenCalled(); + }); + }); + + describe("unloadStripe - multiple instances", () => { + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + beforeEach(async () => { + // Load first instance + service.loadStripe("instance-1", elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + + // Load second instance (script already loaded) + service.loadStripe("instance-2", elementIds, false); + await advanceTimersAndFlush(100); + }); + + it("should not remove script when other instances exist", () => { + expect(service["instanceCount"]).toBe(2); + + service.unloadStripe("instance-1"); + + expect(service["instanceCount"]).toBe(1); + expect(window.document.head.removeChild).not.toHaveBeenCalled(); + }); + + it("should only cleanup specific instance", () => { + service.unloadStripe("instance-1"); + + expect(service["instances"].has("instance-1")).toBe(false); + expect(service["instances"].has("instance-2")).toBe(true); + }); + + it("should handle reference counting correctly", () => { + expect(service["instanceCount"]).toBe(2); + + service.unloadStripe("instance-1"); + expect(service["instanceCount"]).toBe(1); + + service.unloadStripe("instance-2"); + expect(service["instanceCount"]).toBe(0); + }); + }); + + describe("unloadStripe - edge cases", () => { + it("should handle unload of non-existent instance gracefully", () => { + expect(() => service.unloadStripe("non-existent")).not.toThrow(); + expect(service["instanceCount"]).toBe(0); + }); + + it("should handle duplicate unload calls", async () => { + const instanceId = "duplicate-unload"; + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + service.loadStripe(instanceId, elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + + service.unloadStripe(instanceId); + expect(service["instanceCount"]).toBe(0); + + service.unloadStripe(instanceId); + expect(service["instanceCount"]).toBe(0); // Should not go negative + }); + + it("should catch and log element unmount errors", async () => { + const instanceId = "error-unmount"; + const elementIds = { + cardNumber: "#card-number", + cardExpiry: "#card-expiry", + cardCvc: "#card-cvc", + }; + + service.loadStripe(instanceId, elementIds, false); + triggerScriptLoad(); + await advanceTimersAndFlush(100); + + const unmountError = new Error("Unmount failed"); + mockCardNumber.unmount.mockImplementation(() => { + throw unmountError; + }); + + service.unloadStripe(instanceId); + + expect(logService.error).toHaveBeenCalledWith( + expect.stringContaining("Error unmounting Stripe elements"), + unmountError, + ); + }); + }); + + describe("element styling", () => { + it("should apply correct CSS custom properties", () => { + const options = service["getElementOptions"]("cardNumber"); + + expect(options.style.base.color).toBe("rgb(0, 0, 0)"); + expect(options.style.base["::placeholder"].color).toBe("rgb(128, 128, 128)"); + expect(options.style.invalid.color).toBe("rgb(0, 0, 0)"); + expect(options.style.invalid.borderColor).toBe("rgb(220, 38, 38)"); + }); + + it("should remove placeholder for cardNumber and cardCvc", () => { + const cardNumberOptions = service["getElementOptions"]("cardNumber"); + const cardCvcOptions = service["getElementOptions"]("cardCvc"); + const cardExpiryOptions = service["getElementOptions"]("cardExpiry"); + + expect(cardNumberOptions.placeholder).toBe(""); + expect(cardCvcOptions.placeholder).toBe(""); + expect(cardExpiryOptions.placeholder).toBeUndefined(); + }); + }); +}); diff --git a/apps/web/src/app/billing/services/stripe.service.ts b/apps/web/src/app/billing/services/stripe.service.ts index a2eb7cd98f2..9aabab9beb0 100644 --- a/apps/web/src/app/billing/services/stripe.service.ts +++ b/apps/web/src/app/billing/services/stripe.service.ts @@ -8,8 +8,6 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { BankAccountPaymentMethod, CardPaymentMethod } from "../payment/types"; -import { BillingServicesModule } from "./billing-services.module"; - type SetupBankAccountRequest = { payment_method: { us_bank_account: { @@ -39,15 +37,21 @@ type SetupCardRequest = { }; }; -@Injectable({ providedIn: BillingServicesModule }) +@Injectable({ providedIn: "root" }) export class StripeService { - private stripe: any; - private elements: any; - private elementIds: { - cardNumber: string; - cardExpiry: string; - cardCvc: string; - }; + // Shared/Global - One Stripe client for entire application + private stripe: any = null; + private stripeScriptLoaded = false; + private instanceCount = 0; + + // Per-Instance - Isolated Elements for each component + private instances = new Map< + string, + { + elements: any; + elementIds: { cardNumber: string; cardExpiry: string; cardCvc: string }; + } + >(); constructor( private apiService: ApiService, @@ -76,53 +80,121 @@ export class StripeService { * Loads [Stripe JS]{@link https://docs.stripe.com/js} in the element of the current page and mounts * Stripe credit card [elements]{@link https://docs.stripe.com/js/elements_object/create} into the HTML elements with the provided element IDS. * We do this to avoid having to load the Stripe JS SDK on every page of the Web Vault given many pages contain sensitive information. + * @param instanceId - Unique identifier for this component instance. * @param elementIds - The ID attributes of the HTML elements used to load the Stripe JS credit card elements. * @param autoMount - A flag indicating whether you want to immediately mount the Stripe credit card elements. */ loadStripe( + instanceId: string, elementIds: { cardNumber: string; cardExpiry: string; cardCvc: string }, autoMount: boolean, ) { - this.elementIds = elementIds; - const script = window.document.createElement("script"); - script.id = "stripe-script"; - script.src = "https://js.stripe.com/v3?advancedFraudSignals=false"; - script.onload = async () => { - const window$ = window as any; - this.stripe = window$.Stripe(process.env.STRIPE_KEY); - this.elements = this.stripe.elements(); - setTimeout(() => { - this.elements.create("cardNumber", this.getElementOptions("cardNumber")); - this.elements.create("cardExpiry", this.getElementOptions("cardExpiry")); - this.elements.create("cardCvc", this.getElementOptions("cardCvc")); - if (autoMount) { - this.mountElements(); - } - }, 50); - }; + // Check if script is already loaded + if (this.stripeScriptLoaded) { + // Script already loaded, initialize this instance immediately + this.initializeInstance(instanceId, elementIds, autoMount); + } else if (!window.document.getElementById("stripe-script")) { + // Script not loaded and not loading, start loading it + const script = window.document.createElement("script"); + script.id = "stripe-script"; + script.src = "https://js.stripe.com/v3?advancedFraudSignals=false"; + script.onload = async () => { + const window$ = window as any; + this.stripe = window$.Stripe(process.env.STRIPE_KEY); + this.stripeScriptLoaded = true; // Mark as loaded after script loads - window.document.head.appendChild(script); + // Initialize this instance after script loads + this.initializeInstance(instanceId, elementIds, autoMount); + }; + window.document.head.appendChild(script); + } else { + // Script is currently loading, wait for it + this.initializeInstance(instanceId, elementIds, autoMount); + } } - mountElements(attempt: number = 1) { - setTimeout(() => { - if (!this.elements) { - this.logService.warning(`Stripe elements are missing, retrying for attempt ${attempt}...`); - this.mountElements(attempt + 1); + private initializeInstance( + instanceId: string, + elementIds: { cardNumber: string; cardExpiry: string; cardCvc: string }, + autoMount: boolean, + attempt: number = 1, + ) { + // Wait for stripe to be available if script just loaded + if (!this.stripe) { + if (attempt < 10) { + this.logService.warning( + `Stripe not yet loaded for instance ${instanceId}, retrying attempt ${attempt}...`, + ); + setTimeout( + () => this.initializeInstance(instanceId, elementIds, autoMount, attempt + 1), + 50, + ); } else { - const cardNumber = this.elements.getElement("cardNumber"); - const cardExpiry = this.elements.getElement("cardExpiry"); - const cardCVC = this.elements.getElement("cardCvc"); + this.logService.error( + `Stripe failed to load for instance ${instanceId} after ${attempt} attempts`, + ); + } + return; + } + + // Create a new Elements instance for this component + const elements = this.stripe.elements(); + + // Store instance data + this.instances.set(instanceId, { elements, elementIds }); + + // Increment instance count now that instance is successfully initialized + this.instanceCount++; + + // Create the card elements + setTimeout(() => { + elements.create("cardNumber", this.getElementOptions("cardNumber")); + elements.create("cardExpiry", this.getElementOptions("cardExpiry")); + elements.create("cardCvc", this.getElementOptions("cardCvc")); + + if (autoMount) { + this.mountElements(instanceId); + } + }, 50); + } + + mountElements(instanceId: string, attempt: number = 1) { + setTimeout(() => { + const instance = this.instances.get(instanceId); + + if (!instance) { + if (attempt < 10) { + this.logService.warning( + `Stripe instance ${instanceId} not found, retrying for attempt ${attempt}...`, + ); + this.mountElements(instanceId, attempt + 1); + } else { + this.logService.error( + `Stripe instance ${instanceId} not found after ${attempt} attempts`, + ); + } + return; + } + + if (!instance.elements) { + this.logService.warning( + `Stripe elements for instance ${instanceId} are missing, retrying for attempt ${attempt}...`, + ); + this.mountElements(instanceId, attempt + 1); + } else { + const cardNumber = instance.elements.getElement("cardNumber"); + const cardExpiry = instance.elements.getElement("cardExpiry"); + const cardCVC = instance.elements.getElement("cardCvc"); if ([cardNumber, cardExpiry, cardCVC].some((element) => !element)) { this.logService.warning( - `Some Stripe card elements are missing, retrying for attempt ${attempt}...`, + `Some Stripe card elements for instance ${instanceId} are missing, retrying for attempt ${attempt}...`, ); - this.mountElements(attempt + 1); + this.mountElements(instanceId, attempt + 1); } else { - cardNumber.mount(this.elementIds.cardNumber); - cardExpiry.mount(this.elementIds.cardExpiry); - cardCVC.mount(this.elementIds.cardCvc); + cardNumber.mount(instance.elementIds.cardNumber); + cardExpiry.mount(instance.elementIds.cardExpiry); + cardCVC.mount(instance.elementIds.cardCvc); } } }, 100); @@ -132,6 +204,9 @@ export class StripeService { * Creates a Stripe [SetupIntent]{@link https://docs.stripe.com/api/setup_intents} and uses the resulting client secret * to invoke the Stripe JS [confirmUsBankAccountSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_us_bank_account_setup} method, * thereby creating and storing a Stripe [PaymentMethod]{@link https://docs.stripe.com/api/payment_methods}. + * @param clientSecret - The client secret from the SetupIntent. + * @param bankAccount - The bank account details. + * @param billingDetails - Optional billing details. * @returns The ID of the newly created PaymentMethod. */ async setupBankAccountPaymentMethod( @@ -171,13 +246,28 @@ export class StripeService { * Creates a Stripe [SetupIntent]{@link https://docs.stripe.com/api/setup_intents} and uses the resulting client secret * to invoke the Stripe JS [confirmCardSetup]{@link https://docs.stripe.com/js/setup_intents/confirm_card_setup} method, * thereby creating and storing a Stripe [PaymentMethod]{@link https://docs.stripe.com/api/payment_methods}. + * @param instanceId - Unique identifier for the component instance. + * @param clientSecret - The client secret from the SetupIntent. + * @param billingDetails - Optional billing details. * @returns The ID of the newly created PaymentMethod. */ async setupCardPaymentMethod( + instanceId: string, clientSecret: string, billingDetails?: { country: string; postalCode: string }, ): Promise { - const cardNumber = this.elements.getElement("cardNumber"); + const instance = this.instances.get(instanceId); + if (!instance) { + const availableInstances = Array.from(this.instances.keys()); + this.logService.error( + `Stripe instance ${instanceId} not found. ` + + `Available instances: [${availableInstances.join(", ")}]. ` + + `This may occur if the component was destroyed during the payment flow.`, + ); + throw new Error("Payment method initialization failed. Please try again."); + } + + const cardNumber = instance.elements.getElement("cardNumber"); const request: SetupCardRequest = { payment_method: { card: cardNumber, @@ -200,24 +290,77 @@ export class StripeService { } /** - * Removes {@link https://docs.stripe.com/js} from the element of the current page as well as all - * Stripe-managed