diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index d6028d106db..154dcb0f72e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -29,6 +29,7 @@ libs/common/src/auth @bitwarden/team-auth-dev apps/browser/src/tools @bitwarden/team-tools-dev apps/cli/src/tools @bitwarden/team-tools-dev apps/desktop/src/app/tools @bitwarden/team-tools-dev +apps/desktop/desktop_native/bitwarden_chromium_importer @bitwarden/team-tools-dev apps/web/src/app/tools @bitwarden/team-tools-dev libs/angular/src/tools @bitwarden/team-tools-dev libs/common/src/models/export @bitwarden/team-tools-dev @@ -215,3 +216,4 @@ apps/web/src/locales/en/messages.json **/tsconfig.json @bitwarden/team-platform-dev **/jest.config.js @bitwarden/team-platform-dev **/project.jsons @bitwarden/team-platform-dev +libs/pricing @bitwarden/team-billing-dev diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 43661d50910..823cb7e25e0 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -123,11 +123,20 @@ jobs: build-source: - name: Build browser source + name: Build browser source - ${{matrix.license_type.readable}} runs-on: ubuntu-22.04 needs: - setup - locales-test + strategy: + matrix: + license_type: + - include_bitwarden_license_folder: false + archive_name_prefix: "" + readable: "open source license" + - include_bitwarden_license_folder: true + archive_name_prefix: "bit-" + readable: "commercial license" env: _BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -166,6 +175,12 @@ jobs: mkdir -p browser-source/apps/browser cp -r apps/browser/* browser-source/apps/browser + # Copy bitwarden_license/bit-browser to the Browser source directory + if [[ ${{matrix.license_type.include_bitwarden_license_folder}} == "true" ]]; then + mkdir -p browser-source/bitwarden_license/bit-browser + cp -r bitwarden_license/bit-browser/* browser-source/bitwarden_license/bit-browser + fi + # Copy libs to Browser source directory mkdir browser-source/libs cp -r libs/* browser-source/libs @@ -175,13 +190,13 @@ jobs: - name: Upload browser source uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: - name: browser-source-${{ env._BUILD_NUMBER }}.zip + name: ${{matrix.license_type.archive_name_prefix}}browser-source-${{ env._BUILD_NUMBER }}.zip path: browser-source.zip if-no-files-found: error build: - name: Build + name: Build ${{ matrix.browser.name }} - ${{ matrix.license_type.readable }} runs-on: ubuntu-22.04 needs: - setup @@ -192,25 +207,38 @@ jobs: _NODE_VERSION: ${{ needs.setup.outputs.node_version }} strategy: matrix: - include: + license_type: + - build_prefix: "" + artifact_prefix: "" + source_archive_name_prefix: "" + archive_name_prefix: "" + npm_command_prefix: "dist:" + readable: "open source license" + - build_prefix: "bit-" + artifact_prefix: "bit-" + source_archive_name_prefix: "bit-" + archive_name_prefix: "bit-" + npm_command_prefix: "dist:bit:" + readable: "commercial license" + browser: - name: "chrome" - npm_command: "dist:chrome" + npm_command_suffix: "chrome" archive_name: "dist-chrome.zip" artifact_name: "dist-chrome-MV3" - name: "edge" - npm_command: "dist:edge" + npm_command_suffix: "edge" archive_name: "dist-edge.zip" artifact_name: "dist-edge-MV3" - name: "firefox" - npm_command: "dist:firefox" + npm_command_suffix: "firefox" archive_name: "dist-firefox.zip" artifact_name: "dist-firefox" - name: "firefox-mv3" - npm_command: "dist:firefox:mv3" + npm_command_suffix: "firefox:mv3" archive_name: "dist-firefox.zip" artifact_name: "DO-NOT-USE-FOR-PROD-dist-firefox-MV3" - name: "opera-mv3" - npm_command: "dist:opera:mv3" + npm_command_suffix: "opera:mv3" archive_name: "dist-opera.zip" artifact_name: "dist-opera-MV3" steps: @@ -234,7 +262,7 @@ jobs: - name: Download browser source uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 with: - name: browser-source-${{ env._BUILD_NUMBER }}.zip + name: ${{matrix.license_type.source_archive_name_prefix}}browser-source-${{ env._BUILD_NUMBER }}.zip - name: Unzip browser source artifact run: | @@ -264,7 +292,7 @@ jobs: run: npm link ../sdk-internal - name: Check source file size - if: ${{ startsWith(matrix.name, 'firefox') }} + if: ${{ startsWith(matrix.browser.name, 'firefox') }} run: | # Declare variable as indexed array declare -a FILES @@ -287,19 +315,19 @@ jobs: fi - name: Build extension - run: npm run ${{ matrix.npm_command }} + run: npm run ${{matrix.license_type.npm_command_prefix}}${{ matrix.browser.npm_command_suffix }} working-directory: browser-source/apps/browser - name: Upload extension artifact uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: - name: ${{ matrix.artifact_name }}-${{ env._BUILD_NUMBER }}.zip - path: browser-source/apps/browser/dist/${{ matrix.archive_name }} + 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 build-safari: - name: Build Safari + name: Build Safari - ${{ matrix.license_type.readable }} runs-on: macos-13 permissions: contents: read @@ -308,6 +336,19 @@ jobs: - setup - locales-test if: ${{ needs.setup.outputs.has_secrets == 'true' }} + strategy: + matrix: + license_type: + - build_prefix: "" + artifact_prefix: "" + archive_name_prefix: "" + npm_command_prefix: "dist:" + readable: "open source license" + - build_prefix: "bit-" + artifact_prefix: "bit-" + archive_name_prefix: "bit-" + npm_command_prefix: "dist:bit:" + readable: "commercial license" env: _BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }} _NODE_VERSION: ${{ needs.setup.outputs.node_version }} @@ -433,21 +474,21 @@ jobs: npm link ../sdk-internal - name: Build Safari extension - run: npm run dist:safari + run: npm run ${{matrix.license_type.npm_command_prefix}}safari working-directory: apps/browser - name: Zip Safari build artifact run: | cd apps/browser/dist - zip dist-safari.zip ./Safari/**/build/Release/safari.appex -r + zip ${{matrix.license_type.archive_name_prefix }}dist-safari.zip ./Safari/**/build/Release/safari.appex -r pwd ls -la - name: Upload Safari artifact uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 with: - name: dist-safari-${{ env._BUILD_NUMBER }}.zip - path: apps/browser/dist/dist-safari.zip + 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 if-no-files-found: error crowdin-push: diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml deleted file mode 100644 index eb6af20f9ee..00000000000 --- a/.github/workflows/release-desktop-beta.yml +++ /dev/null @@ -1,1104 +0,0 @@ -name: Release Desktop Beta - -on: - workflow_dispatch: - inputs: - version_number: - description: "New Beta Version" - required: true - -defaults: - run: - shell: bash - -jobs: - setup: - name: Setup - runs-on: ubuntu-22.04 - permissions: - contents: write - outputs: - release_version: ${{ steps.version.outputs.version }} - release_channel: ${{ steps.release_channel.outputs.channel }} - branch_name: ${{ steps.branch.outputs.branch_name }} - build_number: ${{ steps.increment-version.outputs.build_number }} - node_version: ${{ steps.retrieve-node-version.outputs.node_version }} - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Branch check - run: | - if [[ "$GITHUB_REF" != "refs/heads/main" ]] && [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix-rc" ]]; then - echo "===================================" - echo "[!] Can only release from the 'main', 'rc' or 'hotfix-rc' branches" - echo "===================================" - exit 1 - fi - - - name: Bump Desktop Version - Root - env: - VERSION: ${{ github.event.inputs.version_number }} - run: npm version --workspace=@bitwarden/desktop ${VERSION}-beta - - - name: Bump Desktop Version - App - env: - VERSION: ${{ github.event.inputs.version_number }} - run: npm version ${VERSION}-beta - working-directory: "apps/desktop/src" - - - name: Check Release Version - id: version - uses: bitwarden/gh-actions/release-version-check@main - with: - release-type: 'Initial Release' - project-type: ts - file: apps/desktop/src/package.json - monorepo: true - monorepo-project: desktop - - - name: Increment Version - id: increment-version - run: | - BUILD_NUMBER=$(expr 3000 + $GITHUB_RUN_NUMBER) - echo "Setting build number to $BUILD_NUMBER" - echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT - - - name: Get Version Channel - id: release_channel - run: | - case "${{ steps.version.outputs.version }}" in - *"alpha"*) - echo "channel=alpha" >> $GITHUB_OUTPUT - echo "[!] We do not yet support 'alpha'" - exit 1 - ;; - *"beta"*) - echo "channel=beta" >> $GITHUB_OUTPUT - ;; - *) - echo "channel=latest" >> $GITHUB_OUTPUT - ;; - esac - - - name: Setup git config - run: | - git config --global user.name "GitHub Action Bot" - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --global url."https://github.com/".insteadOf ssh://git@github.com/ - git config --global url."https://".insteadOf ssh:// - - - name: Create desktop-beta-release branch - id: branch - env: - VERSION: ${{ github.event.inputs.version_number }} - run: | - find="." - replace="_" - ver=${VERSION//$find/$replace} - branch_name=desktop-beta-release-$ver-beta - - git switch -c $branch_name - git add . - git commit -m "Bump desktop version to $VERSION-beta" - - git push -u origin $branch_name - - echo "branch_name=$branch_name" >> $GITHUB_OUTPUT - - - name: Get Node Version - id: retrieve-node-version - run: | - NODE_NVMRC=$(cat .nvmrc) - NODE_VERSION=${NODE_NVMRC/v/''} - echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - - linux: - name: Linux Build - runs-on: ubuntu-22.04 - needs: setup - permissions: - contents: read - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - defaults: - run: - working-directory: apps/desktop - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Set up environment - run: | - sudo apt-get update - sudo apt-get -y install pkg-config libxss-dev rpm - - - name: Set up Snap - run: sudo snap install snapcraft --classic - - - name: Print environment - run: | - node --version - npm --version - snap --version - snapcraft --version || echo 'snapcraft unavailable' - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build application - run: npm run dist:lin - - - name: Upload .deb artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.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 - 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 .freebsd artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.freebsd - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.freebsd - if-no-files-found: error - - - name: Upload .snap artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.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 - 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 - with: - name: ${{ needs.setup.outputs.release_channel }}-linux.yml - path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-linux.yml - if-no-files-found: error - - - windows: - name: Windows Build - runs-on: windows-2022 - needs: setup - permissions: - contents: read - id-token: write - defaults: - run: - shell: pwsh - working-directory: apps/desktop - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Install AST - run: dotnet tool install --global AzureSignTool --version 4.0.1 - - - name: Set up environment - run: choco install checksum --no-progress - - - name: Print environment - run: | - node --version - npm --version - choco --version - - - name: Log in to Azure - uses: bitwarden/gh-actions/azure-login@main - with: - subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - tenant_id: ${{ secrets.AZURE_TENANT_ID }} - client_id: ${{ secrets.AZURE_CLIENT_ID }} - - - name: Retrieve secrets - id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "code-signing-vault-url, - code-signing-client-id, - code-signing-tenant-id, - code-signing-client-secret, - code-signing-cert-name" - - - name: Log out from Azure - uses: bitwarden/gh-actions/azure-logout@main - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build & Sign (dev) - env: - ELECTRON_BUILDER_SIGN: 1 - SIGNING_VAULT_URL: ${{ steps.retrieve-secrets.outputs.code-signing-vault-url }} - SIGNING_CLIENT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-client-id }} - SIGNING_TENANT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-tenant-id }} - SIGNING_CLIENT_SECRET: ${{ steps.retrieve-secrets.outputs.code-signing-client-secret }} - SIGNING_CERT_NAME: ${{ steps.retrieve-secrets.outputs.code-signing-cert-name }} - run: | - npm run build - npm run pack:win - - - name: Rename appx files for store - run: | - Copy-Item "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx" ` - -Destination "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx" - Copy-Item "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx" ` - -Destination "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx" - Copy-Item "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx" ` - -Destination "./dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx" - - - name: Package for Chocolatey - run: | - Copy-Item -Path ./stores/chocolatey -Destination ./dist/chocolatey -Recurse - Copy-Item -Path ./dist/nsis-web/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe ` - -Destination ./dist/chocolatey - - $checksum = checksum -t sha256 ./dist/chocolatey/Bitwarden-Installer-${{ env._PACKAGE_VERSION }}.exe - $chocoInstall = "./dist/chocolatey/tools/chocolateyinstall.ps1" - (Get-Content $chocoInstall).replace('__version__', "$env:_PACKAGE_VERSION").replace('__checksum__', $checksum) | Set-Content $chocoInstall - choco pack ./dist/chocolatey/bitwarden.nuspec --version "$env:_PACKAGE_VERSION" --out ./dist/chocolatey - - - name: Fix NSIS artifact names for auto-updater - run: | - Rename-Item -Path .\dist\nsis-web\Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z ` - -NewName bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z - Rename-Item -Path .\dist\nsis-web\Bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z ` - -NewName bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z - Rename-Item -Path .\dist\nsis-web\Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z ` - -NewName bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z - - - name: Upload portable exe artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe - path: apps/desktop/dist/Bitwarden-Portable-${{ env._PACKAGE_VERSION }}.exe - if-no-files-found: error - - - name: Upload installer exe artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - 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 - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32.appx - if-no-files-found: error - - - name: Upload store appx ia32 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-ia32-store.appx - if-no-files-found: error - - - name: Upload NSIS ia32 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z - path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z - if-no-files-found: error - - - name: Upload appx x64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64.appx - if-no-files-found: error - - - name: Upload store appx x64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-x64-store.appx - if-no-files-found: error - - - name: Upload NSIS x64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z - path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-x64.nsis.7z - if-no-files-found: error - - - name: Upload appx ARM64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64.appx - if-no-files-found: error - - - name: Upload store appx ARM64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx - path: apps/desktop/dist/Bitwarden-${{ env._PACKAGE_VERSION }}-arm64-store.appx - if-no-files-found: error - - - name: Upload NSIS ARM64 artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z - path: apps/desktop/dist/nsis-web/bitwarden-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z - if-no-files-found: error - - - name: Upload nupkg artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: bitwarden.${{ env._PACKAGE_VERSION }}.nupkg - path: apps/desktop/dist/chocolatey/bitwarden.${{ env._PACKAGE_VERSION }}.nupkg - if-no-files-found: error - - - name: Upload auto-update artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: ${{ needs.setup.outputs.release_channel }}.yml - path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml - if-no-files-found: error - - - macos-build: - name: MacOS Build - runs-on: macos-13 - needs: setup - permissions: - contents: read - id-token: write - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - defaults: - run: - working-directory: apps/desktop - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Set up Node-gyp - run: python3 -m pip install setuptools - - - name: Print environment - run: | - node --version - npm --version - echo "GitHub ref: $GITHUB_REF" - echo "GitHub event: $GITHUB_EVENT" - - - name: Cache Build - id: build-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/desktop/build - key: ${{ runner.os }}-${{ github.run_id }}-build - - - name: Cache Safari - id: safari-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/browser/dist/Safari - key: ${{ runner.os }}-${{ github.run_id }}-safari-extension - - - name: Log in to Azure - uses: bitwarden/gh-actions/azure-login@main - with: - subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - tenant_id: ${{ secrets.AZURE_TENANT_ID }} - client_id: ${{ secrets.AZURE_CLIENT_ID }} - - - name: Get Azure Key Vault secrets - id: get-kv-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: gh-clients - secrets: "KEYCHAIN-PASSWORD" - - - name: Download Provisioning Profiles secrets - env: - ACCOUNT_NAME: bitwardenci - CONTAINER_NAME: profiles - run: | - mkdir -p $HOME/secrets - - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ - --name bitwarden_desktop_appstore.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - --output none - - - name: Get certificates - run: | - mkdir -p $HOME/certificates - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | - jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-installer-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-installer-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | - jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 - - - name: Log out from Azure - uses: bitwarden/gh-actions/azure-logout@main - - - name: Set up keychain - env: - KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} - run: | - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain - security set-keychain-settings -lut 1200 build.keychain - security import "$HOME/certificates/bitwarden-desktop-key.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/macdev-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain - - - name: Set up provisioning profiles - run: | - cp $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - $GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_appstore.provisionprofile - - - name: Increment version - shell: pwsh - env: - BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} - run: | - $package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json - $package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER" - $package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build application (dev) - run: npm run build - - - macos-package-github: - name: MacOS Package GitHub Release Assets - runs-on: macos-13 - needs: - - setup - - macos-build - permissions: - contents: read - packages: read - id-token: write - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - defaults: - run: - working-directory: apps/desktop - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Set up Node-gyp - run: python3 -m pip install setuptools - - - name: Print environment - run: | - node --version - npm --version - echo "GitHub ref: $GITHUB_REF" - echo "GitHub event: $GITHUB_EVENT" - - - name: Get Build Cache - id: build-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/desktop/build - key: ${{ runner.os }}-${{ github.run_id }}-build - - - name: Setup Safari Cache - id: safari-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/browser/dist/Safari - key: ${{ runner.os }}-${{ github.run_id }}-safari-extension - - - name: Log in to Azure - uses: bitwarden/gh-actions/azure-login@main - with: - subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - tenant_id: ${{ secrets.AZURE_TENANT_ID }} - client_id: ${{ secrets.AZURE_CLIENT_ID }} - - - name: Get Azure Key Vault secrets - id: get-kv-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: gh-clients - secrets: "KEYCHAIN-PASSWORD,APPLE-ID-USERNAME,APPLE-ID-PASSWORD" - - - name: Download Provisioning Profiles secrets - env: - ACCOUNT_NAME: bitwardenci - CONTAINER_NAME: profiles - run: | - mkdir -p $HOME/secrets - - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ - --name bitwarden_desktop_appstore.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - --output none - - - name: Get certificates - run: | - mkdir -p $HOME/certificates - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | - jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-installer-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-installer-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | - jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 - - - name: Log out from Azure - uses: bitwarden/gh-actions/azure-logout@main - - - name: Set up keychain - env: - KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} - run: | - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain - security set-keychain-settings -lut 1200 build.keychain - - security import "$HOME/certificates/bitwarden-desktop-key.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/macdev-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain - - - name: Set up provisioning profiles - run: | - cp $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - $GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_appstore.provisionprofile - - - name: Increment version - shell: pwsh - env: - BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} - run: | - $package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json - $package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER" - $package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm run build - - - name: Download artifact from hotfix-rc - if: github.ref == 'refs/heads/hotfix-rc' - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: hotfix-rc - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Download artifact from rc - if: github.ref == 'refs/heads/rc' - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: rc - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Download artifacts from main - if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }} - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: main - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Unzip Safari artifact - run: | - SAFARI_DIR=$(find $GITHUB_WORKSPACE/browser-build-artifacts -name 'dist-safari-*.zip') - echo $SAFARI_DIR - unzip $SAFARI_DIR/dist-safari.zip -d $GITHUB_WORKSPACE/browser-build-artifacts - - - name: Load Safari extension for .dmg - run: | - mkdir PlugIns - cp -r $GITHUB_WORKSPACE/browser-build-artifacts/Safari/dmg/build/Release/safari.appex PlugIns/safari.appex - - - name: Build application (dist) - env: - APPLE_ID_USERNAME: ${{ steps.get-kv-secrets.outputs.APPLE-ID-USERNAME }} - APPLE_ID_PASSWORD: ${{ steps.get-kv-secrets.outputs.APPLE-ID-PASSWORD }} - run: npm run pack:mac - - - name: Upload .zip artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.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 - 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 - 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 - with: - name: ${{ needs.setup.outputs.release_channel }}-mac.yml - path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-mac.yml - if-no-files-found: error - - - macos-package-mas: - name: MacOS Package Prod Release Asset - runs-on: macos-13 - needs: - - setup - - macos-build - permissions: - contents: read - packages: read - id-token: write - env: - _PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }} - _NODE_VERSION: ${{ needs.setup.outputs.node_version }} - NODE_OPTIONS: --max_old_space_size=4096 - defaults: - run: - working-directory: apps/desktop - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - ref: ${{ needs.setup.outputs.branch_name }} - - - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - cache: 'npm' - cache-dependency-path: '**/package-lock.json' - node-version: ${{ env._NODE_VERSION }} - - - name: Set up Node-gyp - run: python3 -m pip install setuptools - - - name: Print environment - run: | - node --version - npm --version - echo "GitHub ref: $GITHUB_REF" - echo "GitHub event: $GITHUB_EVENT" - - - name: Get Build Cache - id: build-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/desktop/build - key: ${{ runner.os }}-${{ github.run_id }}-build - - - name: Setup Safari Cache - id: safari-cache - uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: apps/browser/dist/Safari - key: ${{ runner.os }}-${{ github.run_id }}-safari-extension - - - name: Log in to Azure - uses: bitwarden/gh-actions/azure-login@main - with: - subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - tenant_id: ${{ secrets.AZURE_TENANT_ID }} - client_id: ${{ secrets.AZURE_CLIENT_ID }} - - - name: Get Azure Key Vault secrets - id: get-kv-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: gh-clients - secrets: "KEYCHAIN-PASSWORD,APPLE-ID-USERNAME,APPLE-ID-PASSWORD" - - - name: Download Provisioning Profiles secrets - env: - ACCOUNT_NAME: bitwardenci - CONTAINER_NAME: profiles - run: | - mkdir -p $HOME/secrets - - az storage blob download --account-name $ACCOUNT_NAME --container-name $CONTAINER_NAME \ - --name bitwarden_desktop_appstore.provisionprofile \ - --file $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - --output none - - - name: Get certificates - run: | - mkdir -p $HOME/certificates - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/bitwarden-desktop-key | - jq -r .value | base64 -d > $HOME/certificates/bitwarden-desktop-key.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-app-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/appstore-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/appstore-installer-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-installer-cert | - jq -r .value | base64 -d > $HOME/certificates/devid-installer-cert.p12 - - az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert | - jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12 - - - name: Log out from Azure - uses: bitwarden/gh-actions/azure-logout@main - - - name: Set up keychain - env: - KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }} - run: | - security create-keychain -p $KEYCHAIN_PASSWORD build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain - security set-keychain-settings -lut 1200 build.keychain - - security import "$HOME/certificates/bitwarden-desktop-key.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/devid-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-app-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/appstore-installer-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security import "$HOME/certificates/macdev-cert.p12" -k build.keychain -P "" \ - -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild - - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain - - - name: Set up provisioning profiles - run: | - cp $HOME/secrets/bitwarden_desktop_appstore.provisionprofile \ - $GITHUB_WORKSPACE/apps/desktop/bitwarden_desktop_appstore.provisionprofile - - - name: Increment version - shell: pwsh - env: - BUILD_NUMBER: ${{ needs.setup.outputs.build_number }} - run: | - $package = Get-Content -Raw -Path electron-builder.json | ConvertFrom-Json - $package | Add-Member -MemberType NoteProperty -Name buildVersion -Value "$env:BUILD_NUMBER" - $package | ConvertTo-Json -Depth 32 | Set-Content -Path electron-builder.json - - - name: Install Node dependencies - run: npm ci - working-directory: ./ - - - name: Build - if: steps.build-cache.outputs.cache-hit != 'true' - run: npm run build - - - name: Download artifact from hotfix-rc - if: github.ref == 'refs/heads/hotfix-rc' - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: hotfix-rc - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Download artifact from rc - if: github.ref == 'refs/heads/rc' - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: rc - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Download artifact from main - if: ${{ github.ref != 'refs/heads/rc' && github.ref != 'refs/heads/hotfix-rc' }} - uses: bitwarden/gh-actions/download-artifacts@main - with: - workflow: build-browser.yml - workflow_conclusion: success - branch: main - path: ${{ github.workspace }}/browser-build-artifacts - - - name: Unzip Safari artifact - run: | - SAFARI_DIR=$(find $GITHUB_WORKSPACE/browser-build-artifacts -name 'dist-safari-*.zip') - echo $SAFARI_DIR - unzip $SAFARI_DIR/dist-safari.zip -d $GITHUB_WORKSPACE/browser-build-artifacts - - - name: Load Safari extension for App Store - run: | - mkdir PlugIns - cp -r $GITHUB_WORKSPACE/browser-build-artifacts/Safari/mas/build/Release/safari.appex PlugIns/safari.appex - - - name: Build application for App Store - run: npm run pack:mac:mas - env: - APPLE_ID_USERNAME: ${{ steps.get-kv-secrets.outputs.APPLE-ID-USERNAME }} - APPLE_ID_PASSWORD: ${{ steps.get-kv-secrets.outputs.APPLE-ID-PASSWORD }} - - - name: Upload .pkg artifact - uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 - with: - name: Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg - path: apps/desktop/dist/mas-universal/Bitwarden-${{ env._PACKAGE_VERSION }}-universal.pkg - if-no-files-found: error - - release: - name: Release beta channel to S3 - runs-on: ubuntu-22.04 - needs: - - setup - - linux - - windows - - macos-build - - macos-package-github - - macos-package-mas - permissions: - contents: read - id-token: write - deployments: write - steps: - - name: Create GitHub deployment - uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7 - id: deployment - with: - token: '${{ secrets.GITHUB_TOKEN }}' - initial-status: 'in_progress' - environment: 'Desktop - Beta' - description: 'Deployment ${{ needs.setup.outputs.release_version }} to channel ${{ needs.setup.outputs.release_channel }} from branch ${{ needs.setup.outputs.branch_name }}' - task: release - - - name: Log in to Azure - uses: bitwarden/gh-actions/azure-login@main - with: - subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - tenant_id: ${{ secrets.AZURE_TENANT_ID }} - client_id: ${{ secrets.AZURE_CLIENT_ID }} - - - name: Retrieve secrets - id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@main - with: - keyvault: "bitwarden-ci" - secrets: "aws-electron-access-id, - aws-electron-access-key, - aws-electron-bucket-name" - - - name: Log out from Azure - uses: bitwarden/gh-actions/azure-logout@main - - - name: Download all artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 - with: - path: apps/desktop/artifacts - - - name: Rename .pkg to .pkg.archive - env: - PKG_VERSION: ${{ needs.setup.outputs.release_version }} - working-directory: apps/desktop/artifacts - run: mv Bitwarden-${{ env.PKG_VERSION }}-universal.pkg Bitwarden-${{ env.PKG_VERSION }}-universal.pkg.archive - - - name: Publish artifacts to S3 - env: - AWS_ACCESS_KEY_ID: ${{ steps.retrieve-secrets.outputs.aws-electron-access-id }} - AWS_SECRET_ACCESS_KEY: ${{ steps.retrieve-secrets.outputs.aws-electron-access-key }} - AWS_DEFAULT_REGION: 'us-west-2' - AWS_S3_BUCKET_NAME: ${{ steps.retrieve-secrets.outputs.aws-electron-bucket-name }} - working-directory: apps/desktop/artifacts - run: | - aws s3 cp ./ $AWS_S3_BUCKET_NAME/desktop/ \ - --acl "public-read" \ - --recursive \ - --quiet - - - name: Update deployment status to Success - if: ${{ success() }} - uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3 - with: - token: '${{ secrets.GITHUB_TOKEN }}' - state: 'success' - deployment-id: ${{ steps.deployment.outputs.deployment_id }} - - - name: Update deployment status to Failure - if: ${{ failure() }} - uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3 - with: - token: '${{ secrets.GITHUB_TOKEN }}' - state: 'failure' - deployment-id: ${{ steps.deployment.outputs.deployment_id }} - - remove-branch: - name: Remove branch - runs-on: ubuntu-22.04 - if: always() - needs: - - setup - - linux - - windows - - macos-build - - macos-package-github - - macos-package-mas - - release - permissions: - contents: write - steps: - - name: Checkout repo - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Setup git config - run: | - git config --global user.name "GitHub Action Bot" - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --global url."https://github.com/".insteadOf ssh://git@github.com/ - git config --global url."https://".insteadOf ssh:// - - name: Remove branch - env: - BRANCH: ${{ needs.setup.outputs.branch_name }} - run: git push origin --delete $BRANCH diff --git a/.storybook/main.ts b/.storybook/main.ts index 879e87fe376..d3811bb178d 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -10,6 +10,8 @@ const config: StorybookConfig = { "../libs/auth/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/dirt/card/src/**/*.mdx", "../libs/dirt/card/src/**/*.stories.@(js|jsx|ts|tsx)", + "../libs/pricing/src/**/*.mdx", + "../libs/pricing/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/tools/send/send-ui/src/**/*.mdx", "../libs/tools/send/send-ui/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/vault/src/**/*.mdx", diff --git a/apps/browser/package.json b/apps/browser/package.json index 3cfc4377227..bfa152f236a 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -3,32 +3,58 @@ "version": "2025.8.2", "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: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", + "build:bit:firefox": "cross-env BROWSER=firefox NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js", "build:opera": "cross-env BROWSER=opera MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", + "build:bit:opera": "cross-env BROWSER=opera MANIFEST_VERSION=3 NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js", "build:safari": "cross-env BROWSER=safari NODE_OPTIONS=\"--max-old-space-size=8192\" webpack", + "build:bit:safari": "cross-env BROWSER=safari NODE_OPTIONS=\"--max-old-space-size=8192\" webpack -c ../../bitwarden_license/bit-browser/webpack.config.js", "build:watch": "npm run build:watch:chrome", "build:watch:chrome": "npm run build:chrome -- --watch", + "build:bit:watch:chrome": "npm run build:bit:chrome -- --watch", "build:watch:edge": "npm run build:edge -- --watch", + "build:bit:watch:edge": "npm run build:bit:edge -- --watch", "build:watch:firefox": "npm run build:firefox -- --watch", + "build:bit:watch:firefox": "npm run build:bit:firefox -- --watch", "build:watch:opera": "npm run build:opera -- --watch", + "build:bit:watch:opera": "npm run build:bit:opera -- --watch", "build:watch:safari": "npm run build:safari -- --watch", - "build:watch:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run build:firefox -- --watch", - "build:watch:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run build:safari -- --watch", + "build:bit:watch:safari": "npm run build:bit:safari -- --watch", + "build:watch:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run build:watch:firefox", + "build:bit:watch:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run build:bit:watch:firefox", + "build:watch:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run build:watch:safari", + "build:bit:watch:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run build:bit:watch:safari", "build:prod:chrome": "cross-env NODE_ENV=production npm run build:chrome", + "build:bit:prod:chrome": "cross-env NODE_ENV=production npm run build:bit:chrome", "build:prod:edge": "cross-env NODE_ENV=production npm run build:edge", + "build:bit:prod:edge": "cross-env NODE_ENV=production npm run build:bit:edge", "build:prod:firefox": "cross-env NODE_ENV=production npm run build:firefox", + "build:bit:prod:firefox": "cross-env NODE_ENV=production npm run build:bit:firefox", "build:prod:opera": "cross-env NODE_ENV=production npm run build:opera", + "build:bit:prod:opera": "cross-env NODE_ENV=production npm run build:bit:opera", "build:prod:safari": "cross-env NODE_ENV=production npm run build:safari", + "build:bit:prod:safari": "cross-env NODE_ENV=production npm run build:bit:safari", "dist:chrome": "npm run build:prod:chrome && mkdir -p dist && ./scripts/compress.sh dist-chrome.zip", + "dist:bit:chrome": "npm run build:bit:prod:chrome && mkdir -p dist && ./scripts/compress.sh bit-dist-chrome.zip", "dist:edge": "npm run build:prod:edge && mkdir -p dist && ./scripts/compress.sh dist-edge.zip", + "dist:bit:edge": "npm run build:bit:prod:edge && mkdir -p dist && ./scripts/compress.sh bit-dist-edge.zip", "dist:firefox": "npm run build:prod:firefox && mkdir -p dist && ./scripts/compress.sh dist-firefox.zip", + "dist:bit:firefox": "npm run build:bit:prod:firefox && mkdir -p dist && ./scripts/compress.sh bit-dist-firefox.zip", "dist:opera": "npm run build:prod:opera && mkdir -p dist && ./scripts/compress.sh dist-opera.zip", + "dist:bit:opera": "npm run build:bit:prod:opera && mkdir -p dist && ./scripts/compress.sh bit-dist-opera.zip", "dist:safari": "npm run build:prod:safari && ./scripts/package-safari.ps1", + "dist:bit:safari": "npm run build:bit:prod:safari && ./scripts/package-safari.ps1", "dist:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:firefox", + "dist:bit:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:bit:firefox", "dist:opera:mv3": "cross-env MANIFEST_VERSION=3 npm run dist:opera", + "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", "test": "jest", "test:watch": "jest --watch", "test:watch:all": "jest --watchAll", diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index bb2483daf3b..ca8e7da5f7b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5588,8 +5588,14 @@ "showLess": { "message": "Show less" }, + "next": { + "message": "Next" + }, "moreBreadcrumbs": { "message": "More breadcrumbs", "description": "This is used in the context of a breadcrumb navigation, indicating that there are more items in the breadcrumb trail that are not currently displayed." + }, + "confirmKeyConnectorDomain": { + "message": "Confirm Key Connector domain" } } diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index df29502edeb..4d227330184 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -158,10 +158,13 @@ import { ApiService } from "@bitwarden/common/services/api.service"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; +import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider"; +import { buildExtensionRegistry } from "@bitwarden/common/tools/extension/factory"; import { PasswordStrengthService, PasswordStrengthServiceAbstraction, } from "@bitwarden/common/tools/password-strength"; +import { createSystemServiceProvider } from "@bitwarden/common/tools/providers"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; import { SendApiService as SendApiServiceAbstraction } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; @@ -302,6 +305,7 @@ import { OffscreenStorageService } from "../platform/storage/offscreen-storage.s import { SyncServiceListener } from "../platform/sync/sync-service.listener"; import { BrowserSystemNotificationService } from "../platform/system-notifications/browser-system-notification.service"; import { fromChromeRuntimeMessaging } from "../platform/utils/from-chrome-runtime-messaging"; +import { AtRiskCipherBadgeUpdaterService } from "../vault/services/at-risk-cipher-badge-updater.service"; import CommandsBackground from "./commands.background"; import IdleBackground from "./idle.background"; @@ -433,6 +437,7 @@ export default class MainBackground { badgeService: BadgeService; authStatusBadgeUpdaterService: AuthStatusBadgeUpdaterService; autofillBadgeUpdaterService: AutofillBadgeUpdaterService; + atRiskCipherUpdaterService: AtRiskCipherBadgeUpdaterService; onUpdatedRan: boolean; onReplacedRan: boolean; @@ -735,6 +740,7 @@ export default class MainBackground { this.logService, (logoutReason: LogoutReason, userId?: UserId) => this.logout(logoutReason, userId), this.vaultTimeoutSettingsService, + this.accountService, { createRequest: (url, request) => new Request(url, request) }, ); @@ -783,6 +789,7 @@ export default class MainBackground { this.kdfConfigService, this.keyService, this.stateProvider, + this.configService, ); this.passwordStrengthService = new PasswordStrengthService(); @@ -841,7 +848,7 @@ export default class MainBackground { this.tokenService, ); - this.configApiService = new ConfigApiService(this.apiService, this.tokenService); + this.configApiService = new ConfigApiService(this.apiService); this.configService = new DefaultConfigService( this.configApiService, @@ -1052,8 +1059,16 @@ export default class MainBackground { this.encryptService, this.pinService, this.accountService, - this.sdkService, this.restrictedItemTypesService, + createSystemServiceProvider( + new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService), + this.stateProvider, + this.policyService, + buildExtensionRegistry(), + this.logService, + this.platformUtilsService, + this.configService, + ), ); this.individualVaultExportService = new IndividualVaultExportService( @@ -1838,6 +1853,14 @@ export default class MainBackground { this.logService, ); + this.atRiskCipherUpdaterService = new AtRiskCipherBadgeUpdaterService( + this.badgeService, + this.accountService, + this.cipherService, + this.logService, + this.taskService, + ); + this.tabsBackground = new TabsBackground( this, this.notificationBackground, @@ -1847,6 +1870,7 @@ export default class MainBackground { await this.overlayBackground.init(); await this.tabsBackground.init(); await this.autofillBadgeUpdaterService.init(); + await this.atRiskCipherUpdaterService.init(); } generatePassword = async (): Promise => { diff --git a/apps/browser/src/images/berry19.png b/apps/browser/src/images/berry19.png new file mode 100644 index 00000000000..51deb3b8d68 Binary files /dev/null and b/apps/browser/src/images/berry19.png differ diff --git a/apps/browser/src/images/berry38.png b/apps/browser/src/images/berry38.png new file mode 100644 index 00000000000..44a67063701 Binary files /dev/null and b/apps/browser/src/images/berry38.png differ diff --git a/apps/browser/src/platform/badge/icon.ts b/apps/browser/src/platform/badge/icon.ts index d6dcdcc5f7d..d60633a0ef1 100644 --- a/apps/browser/src/platform/badge/icon.ts +++ b/apps/browser/src/platform/badge/icon.ts @@ -1,4 +1,8 @@ export const BadgeIcon = { + Berry: { + 19: "/images/berry19.png", + 38: "/images/berry38.png", + }, LoggedOut: { 19: "/images/icon19_gray.png", 38: "/images/icon38_gray.png", diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 34a37da425e..8d190e4555c 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -42,7 +42,7 @@ import { } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AnonLayoutWrapperData } from "@bitwarden/components"; -import { LockComponent } from "@bitwarden/key-management-ui"; +import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component"; import { fido2AuthGuard } from "../auth/popup/guards/fido2-auth.guard"; @@ -598,6 +598,24 @@ const routes: Routes = [ }, ], }, + { + path: "confirm-key-connector-domain", + component: ExtensionAnonLayoutWrapperComponent, + canActivate: [], + data: { elevation: 1 } satisfies RouteDataProperties, + children: [ + { + path: "", + component: ConfirmKeyConnectorDomainComponent, + data: { + pageTitle: { + key: "confirmKeyConnectorDomain", + }, + showBackButton: true, + } satisfies ExtensionAnonLayoutWrapperData, + }, + ], + }, { path: "tabs", component: TabsV2Component, diff --git a/apps/browser/src/popup/app.component.html b/apps/browser/src/popup/app.component.html new file mode 100644 index 00000000000..3d81354ca35 --- /dev/null +++ b/apps/browser/src/popup/app.component.html @@ -0,0 +1,20 @@ +@if (showSdkWarning | async) { +
+ + {{ "wasmNotSupported" | i18n }} + + {{ "learnMore" | i18n }} + + +
+} @else { +
+ +
+ +} diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index ee75dbaf7af..4f46f889eaa 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -57,28 +57,7 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn selector: "app-root", styles: [], animations: [routerTransition], - template: ` - @if (showSdkWarning | async) { -
- - {{ "wasmNotSupported" | i18n }} - - {{ "learnMore" | i18n }} - - -
- } @else { -
- -
- - } - `, + templateUrl: "app.component.html", standalone: false, }) export class AppComponent implements OnInit, OnDestroy { diff --git a/apps/browser/src/popup/main.ts b/apps/browser/src/popup/main.ts index bb975f48e5d..fa6a07d031a 100644 --- a/apps/browser/src/popup/main.ts +++ b/apps/browser/src/popup/main.ts @@ -4,13 +4,10 @@ import { platformBrowserDynamic } from "@angular/platform-browser-dynamic"; import { PopupSizeService } from "../platform/popup/layout/popup-size.service"; import { BrowserPlatformUtilsService } from "../platform/services/platform-utils/browser-platform-utils.service"; -// eslint-disable-next-line @typescript-eslint/no-require-imports -require("./scss/popup.scss"); -// eslint-disable-next-line @typescript-eslint/no-require-imports -require("./scss/tailwind.css"); - import { AppModule } from "./app.module"; +import "./scss"; + // We put these first to minimize the delay in window changing. PopupSizeService.initBodyWidthFromLocalStorage(); // Should be removed once we deprecate support for Safari 16.0 and older. See Jira ticket [PM-1861] diff --git a/apps/browser/src/popup/scss/index.ts b/apps/browser/src/popup/scss/index.ts new file mode 100644 index 00000000000..abb62fa0dd2 --- /dev/null +++ b/apps/browser/src/popup/scss/index.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +require("./popup.scss"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +require("./tailwind.css"); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 1372059d867..d68e71f3bc8 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -43,11 +43,13 @@ import { AccountService, AccountService as AccountServiceAbstraction, } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction"; import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/auth-request-answering.service"; import { AutofillSettingsService, AutofillSettingsServiceAbstraction, @@ -66,7 +68,10 @@ import { KeyGenerationService } from "@bitwarden/common/key-management/crypto"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service"; -import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { + InternalMasterPasswordServiceAbstraction, + MasterPasswordServiceAbstraction, +} from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { VaultTimeoutService, @@ -466,6 +471,19 @@ const safeProviders: SafeProvider[] = [ useClass: InlineDerivedStateProvider, deps: [], }), + safeProvider({ + provide: AuthRequestAnsweringServiceAbstraction, + useClass: AuthRequestAnsweringService, + deps: [ + AccountServiceAbstraction, + ActionsService, + AuthService, + I18nServiceAbstraction, + MasterPasswordServiceAbstraction, + PlatformUtilsService, + SystemNotificationsService, + ], + }), safeProvider({ provide: AutofillSettingsServiceAbstraction, useClass: AutofillSettingsService, diff --git a/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts new file mode 100644 index 00000000000..f2567ef4267 --- /dev/null +++ b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.spec.ts @@ -0,0 +1,84 @@ +import { BehaviorSubject } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SecurityTask, TaskService } from "@bitwarden/common/vault/tasks"; +import { LogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +import { BadgeService } from "../../platform/badge/badge.service"; +import { BadgeIcon } from "../../platform/badge/icon"; +import { BadgeStatePriority } from "../../platform/badge/priority"; +import { Unset } from "../../platform/badge/state"; +import { BrowserApi } from "../../platform/browser/browser-api"; + +import { AtRiskCipherBadgeUpdaterService } from "./at-risk-cipher-badge-updater.service"; + +describe("AtRiskCipherBadgeUpdaterService", () => { + let service: AtRiskCipherBadgeUpdaterService; + + let setState: jest.Mock; + let clearState: jest.Mock; + let warning: jest.Mock; + let getAllDecryptedForUrl: jest.Mock; + let getTab: jest.Mock; + let addListener: jest.Mock; + + const activeAccount$ = new BehaviorSubject({ id: "test-account-id" }); + const cipherViews$ = new BehaviorSubject([]); + const pendingTasks$ = new BehaviorSubject([]); + const userId = "test-user-id" as UserId; + + beforeEach(async () => { + setState = jest.fn().mockResolvedValue(undefined); + clearState = jest.fn().mockResolvedValue(undefined); + warning = jest.fn(); + getAllDecryptedForUrl = jest.fn().mockResolvedValue([]); + getTab = jest.fn(); + addListener = jest.fn(); + + jest.spyOn(BrowserApi, "addListener").mockImplementation(addListener); + jest.spyOn(BrowserApi, "getTab").mockImplementation(getTab); + + service = new AtRiskCipherBadgeUpdaterService( + { setState, clearState } as unknown as BadgeService, + { activeAccount$ } as unknown as AccountService, + { cipherViews$, getAllDecryptedForUrl } as unknown as CipherService, + { warning } as unknown as LogService, + { pendingTasks$ } as unknown as TaskService, + ); + + await service.init(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("clears the tab state when there are no ciphers and no pending tasks", async () => { + const tab = { id: 1 } as chrome.tabs.Tab; + + await service["setTabState"](tab, userId, []); + + expect(clearState).toHaveBeenCalledWith("at-risk-cipher-badge-1"); + }); + + it("sets state when there are pending tasks for the tab", async () => { + const tab = { id: 3, url: "https://bitwarden.com" } as chrome.tabs.Tab; + const pendingTasks: SecurityTask[] = [{ id: "task1", cipherId: "cipher1" } as SecurityTask]; + getAllDecryptedForUrl.mockResolvedValueOnce([{ id: "cipher1" }]); + + await service["setTabState"](tab, userId, pendingTasks); + + expect(setState).toHaveBeenCalledWith( + "at-risk-cipher-badge-3", + BadgeStatePriority.High, + { + icon: BadgeIcon.Berry, + text: Unset, + backgroundColor: Unset, + }, + 3, + ); + }); +}); diff --git a/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts new file mode 100644 index 00000000000..47364958ad8 --- /dev/null +++ b/apps/browser/src/vault/services/at-risk-cipher-badge-updater.service.ts @@ -0,0 +1,163 @@ +import { combineLatest, map, mergeMap, of, Subject, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; +import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; + +import { BadgeService } from "../../platform/badge/badge.service"; +import { BadgeIcon } from "../../platform/badge/icon"; +import { BadgeStatePriority } from "../../platform/badge/priority"; +import { Unset } from "../../platform/badge/state"; +import { BrowserApi } from "../../platform/browser/browser-api"; + +const StateName = (tabId: number) => `at-risk-cipher-badge-${tabId}`; + +export class AtRiskCipherBadgeUpdaterService { + private tabReplaced$ = new Subject<{ addedTab: chrome.tabs.Tab; removedTabId: number }>(); + private tabUpdated$ = new Subject(); + private tabRemoved$ = new Subject(); + private tabActivated$ = new Subject(); + + private activeUserData$ = this.accountService.activeAccount$.pipe( + filterOutNullish(), + switchMap((user) => + combineLatest([ + of(user.id), + this.taskService + .pendingTasks$(user.id) + .pipe( + map((tasks) => tasks.filter((t) => t.type === SecurityTaskType.UpdateAtRiskCredential)), + ), + this.cipherService.cipherViews$(user.id).pipe(filterOutNullish()), + ]), + ), + ); + + constructor( + private badgeService: BadgeService, + private accountService: AccountService, + private cipherService: CipherService, + private logService: LogService, + private taskService: TaskService, + ) { + combineLatest({ + replaced: this.tabReplaced$, + activeUserData: this.activeUserData$, + }) + .pipe( + mergeMap(async ({ replaced, activeUserData: [userId, pendingTasks] }) => { + await this.clearTabState(replaced.removedTabId); + await this.setTabState(replaced.addedTab, userId, pendingTasks); + }), + ) + .subscribe(() => {}); + + combineLatest({ + tab: this.tabActivated$, + activeUserData: this.activeUserData$, + }) + .pipe( + mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => { + await this.setTabState(tab, userId, pendingTasks); + }), + ) + .subscribe(); + + combineLatest({ + tab: this.tabUpdated$, + activeUserData: this.activeUserData$, + }) + .pipe( + mergeMap(async ({ tab, activeUserData: [userId, pendingTasks] }) => { + await this.setTabState(tab, userId, pendingTasks); + }), + ) + .subscribe(); + + this.tabRemoved$ + .pipe( + mergeMap(async (tabId) => { + await this.clearTabState(tabId); + }), + ) + .subscribe(); + } + + init() { + BrowserApi.addListener(chrome.tabs.onReplaced, async (addedTabId, removedTabId) => { + const newTab = await BrowserApi.getTab(addedTabId); + if (!newTab) { + this.logService.warning( + `Tab replaced event received but new tab not found (id: ${addedTabId})`, + ); + return; + } + + this.tabReplaced$.next({ + removedTabId, + addedTab: newTab, + }); + }); + + BrowserApi.addListener(chrome.tabs.onUpdated, (_, changeInfo, tab) => { + if (changeInfo.url) { + this.tabUpdated$.next(tab); + } + }); + + BrowserApi.addListener(chrome.tabs.onActivated, async (activeInfo) => { + const tab = await BrowserApi.getTab(activeInfo.tabId); + if (!tab) { + this.logService.warning( + `Tab activated event received but tab not found (id: ${activeInfo.tabId})`, + ); + return; + } + + this.tabActivated$.next(tab); + }); + + BrowserApi.addListener(chrome.tabs.onRemoved, (tabId, _) => this.tabRemoved$.next(tabId)); + } + + /** Sets the pending task state for the tab */ + private async setTabState(tab: chrome.tabs.Tab, userId: UserId, pendingTasks: SecurityTask[]) { + if (!tab.id) { + this.logService.warning("Tab event received but tab id is undefined"); + return; + } + + const ciphers = tab.url + ? await this.cipherService.getAllDecryptedForUrl(tab.url, userId, [], undefined, true) + : []; + + const hasPendingTasksForTab = pendingTasks.some((task) => + ciphers.some((cipher) => cipher.id === task.cipherId && !cipher.isDeleted), + ); + + if (!hasPendingTasksForTab) { + await this.clearTabState(tab.id); + return; + } + + await this.badgeService.setState( + StateName(tab.id), + BadgeStatePriority.High, + { + icon: BadgeIcon.Berry, + // Unset text and background color to use default badge appearance + text: Unset, + backgroundColor: Unset, + }, + tab.id, + ); + } + + /** Clears the pending task state from a tab */ + private async clearTabState(tabId: number) { + await this.badgeService.clearState(StateName(tabId)); + } +} diff --git a/apps/browser/webpack.base.js b/apps/browser/webpack.base.js new file mode 100644 index 00000000000..872da6600b4 --- /dev/null +++ b/apps/browser/webpack.base.js @@ -0,0 +1,443 @@ +const path = require("path"); +const webpack = require("webpack"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const CopyWebpackPlugin = require("copy-webpack-plugin"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const { AngularWebpackPlugin } = require("@ngtools/webpack"); +const TerserPlugin = require("terser-webpack-plugin"); +const { TsconfigPathsPlugin } = require("tsconfig-paths-webpack-plugin"); +const configurator = require("./config/config"); +const manifest = require("./webpack/manifest"); +const AngularCheckPlugin = require("./webpack/angular-check"); + +module.exports.getEnv = function getEnv() { + const ENV = (process.env.ENV = process.env.NODE_ENV); + const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2; + const browser = process.env.BROWSER ?? "chrome"; + + return { ENV, manifestVersion, browser }; +}; + +/** + * @param {{ + * configName: string; + * popup: { + * entry: string; + * entryModule: string; + * }; + * background: { + * entry: string; + * }; + * tsConfig: string; + * additionalEntries?: { [outputPath: string]: string } + * }} params - The input parameters for building the config. + */ +module.exports.buildConfig = function buildConfig(params) { + if (process.env.NODE_ENV == null) { + process.env.NODE_ENV = "development"; + } + + const { ENV, manifestVersion, browser } = module.exports.getEnv(); + + console.log(`Building Manifest Version ${manifestVersion} app - ${params.configName} version`); + + const envConfig = configurator.load(ENV); + configurator.log(envConfig); + + const moduleRules = [ + { + test: /\.(html)$/, + loader: "html-loader", + }, + { + test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/, + exclude: /loading.svg/, + generator: { + filename: "popup/fonts/[name].[contenthash][ext]", + }, + type: "asset/resource", + }, + { + test: /\.(jpe?g|png|gif|svg)$/i, + exclude: /.*(bwi-font|glyphicons-halflings-regular)\.svg/, + generator: { + filename: "popup/images/[name][ext]", + }, + type: "asset/resource", + }, + { + test: /\.css$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + "css-loader", + "resolve-url-loader", + { + loader: "postcss-loader", + options: { + sourceMap: true, + }, + }, + ], + }, + { + test: /\.scss$/, + use: [ + { + loader: MiniCssExtractPlugin.loader, + }, + "css-loader", + "resolve-url-loader", + { + loader: "sass-loader", + options: { + sourceMap: true, + }, + }, + ], + }, + { + test: /\.[cm]?js$/, + use: [ + { + loader: "babel-loader", + options: { + configFile: "../../babel.config.json", + cacheDirectory: ENV === "development", + compact: ENV !== "development", + }, + }, + ], + }, + { + test: /\.[jt]sx?$/, + loader: "@ngtools/webpack", + }, + ]; + + const requiredPlugins = [ + new webpack.DefinePlugin({ + "process.env": { + ENV: JSON.stringify(ENV), + }, + }), + new webpack.EnvironmentPlugin({ + FLAGS: envConfig.flags, + DEV_FLAGS: ENV === "development" ? envConfig.devFlags : {}, + }), + ]; + + const plugins = [ + new HtmlWebpackPlugin({ + template: "./src/popup/index.ejs", + filename: "popup/index.html", + chunks: ["popup/polyfills", "popup/vendor-angular", "popup/vendor", "popup/main"], + browser: browser, + }), + new HtmlWebpackPlugin({ + template: "./src/autofill/notification/bar.html", + filename: "notification/bar.html", + chunks: ["notification/bar"], + }), + new HtmlWebpackPlugin({ + template: "./src/autofill/overlay/inline-menu/pages/button/button.html", + filename: "overlay/menu-button.html", + chunks: ["overlay/menu-button"], + }), + new HtmlWebpackPlugin({ + template: "./src/autofill/overlay/inline-menu/pages/list/list.html", + filename: "overlay/menu-list.html", + chunks: ["overlay/menu-list"], + }), + new HtmlWebpackPlugin({ + template: "./src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html", + filename: "overlay/menu.html", + chunks: ["overlay/menu"], + }), + new CopyWebpackPlugin({ + patterns: [ + { + from: manifestVersion == 3 ? "./src/manifest.v3.json" : "./src/manifest.json", + to: "manifest.json", + transform: manifest.transform(browser), + }, + { from: "./src/managed_schema.json", to: "managed_schema.json" }, + { from: "./src/_locales", to: "_locales" }, + { from: "./src/images", to: "images" }, + { from: "./src/popup/images", to: "popup/images" }, + { from: "./src/autofill/content/autofill.css", to: "content" }, + ], + }), + new MiniCssExtractPlugin({ + filename: "[name].css", + chunkFilename: "chunk-[id].css", + }), + new AngularWebpackPlugin({ + tsconfig: params.tsConfig, + entryModule: params.popup.entryModule, + sourceMap: true, + }), + new webpack.ProvidePlugin({ + process: "process/browser.js", + }), + new webpack.SourceMapDevToolPlugin({ + exclude: [/content\/.*/, /notification\/.*/, /overlay\/.*/], + filename: "[file].map", + }), + ...requiredPlugins, + ]; + + /** + * @type {import("webpack").Configuration} + * This config compiles everything but the background + */ + const mainConfig = { + name: "main", + mode: ENV, + devtool: false, + entry: { + "popup/polyfills": "./src/popup/polyfills.ts", + "popup/main": params.popup.entry, + "content/trigger-autofill-script-injection": + "./src/autofill/content/trigger-autofill-script-injection.ts", + "content/bootstrap-autofill": "./src/autofill/content/bootstrap-autofill.ts", + "content/bootstrap-autofill-overlay": "./src/autofill/content/bootstrap-autofill-overlay.ts", + "content/bootstrap-autofill-overlay-menu": + "./src/autofill/content/bootstrap-autofill-overlay-menu.ts", + "content/bootstrap-autofill-overlay-notifications": + "./src/autofill/content/bootstrap-autofill-overlay-notifications.ts", + "content/autofiller": "./src/autofill/content/autofiller.ts", + "content/auto-submit-login": "./src/autofill/content/auto-submit-login.ts", + "content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts", + "content/content-message-handler": "./src/autofill/content/content-message-handler.ts", + "content/fido2-content-script": "./src/autofill/fido2/content/fido2-content-script.ts", + "content/fido2-page-script": "./src/autofill/fido2/content/fido2-page-script.ts", + "content/ipc-content-script": "./src/platform/ipc/content/ipc-content-script.ts", + "notification/bar": "./src/autofill/notification/bar.ts", + "overlay/menu-button": + "./src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts", + "overlay/menu-list": + "./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts", + "overlay/menu": + "./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts", + "content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts", + "content/send-popup-open-message": "./src/vault/content/send-popup-open-message.ts", + ...params.additionalEntries, + }, + cache: + ENV !== "development" + ? false + : { + type: "filesystem", + name: "main-cache", + cacheDirectory: path.resolve( + __dirname, + "../../node_modules/.cache/webpack-browser-main", + ), + buildDependencies: { + config: [__filename], + }, + }, + snapshot: { + unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")], + }, + optimization: { + minimize: ENV !== "development", + minimizer: [ + new TerserPlugin({ + exclude: [/content\/.*/, /notification\/.*/, /overlay\/.*/], + terserOptions: { + // Replicate Angular CLI behaviour + compress: { + global_defs: { + ngDevMode: false, + ngI18nClosureMode: false, + }, + }, + }, + }), + ], + splitChunks: { + cacheGroups: { + commons: { + test(module, chunks) { + return ( + module.resource != null && + module.resource.includes(`${path.sep}node_modules${path.sep}`) && + !module.resource.includes(`${path.sep}node_modules${path.sep}@angular${path.sep}`) + ); + }, + name: "popup/vendor", + chunks: (chunk) => { + return chunk.name === "popup/main"; + }, + }, + angular: { + test(module, chunks) { + return ( + module.resource != null && + module.resource.includes(`${path.sep}node_modules${path.sep}@angular${path.sep}`) + ); + }, + name: "popup/vendor-angular", + chunks: (chunk) => { + return chunk.name === "popup/main"; + }, + }, + }, + }, + }, + resolve: { + extensions: [".ts", ".js"], + symlinks: false, + modules: [path.resolve("../../node_modules")], + fallback: { + assert: false, + buffer: require.resolve("buffer/"), + util: require.resolve("util/"), + url: require.resolve("url/"), + fs: false, + path: require.resolve("path-browserify"), + }, + cache: true, + }, + output: { + filename: "[name].js", + chunkFilename: "assets/[name].js", + webassemblyModuleFilename: "assets/[modulehash].wasm", + path: path.resolve(__dirname, "build"), + clean: true, + }, + module: { + rules: moduleRules, + }, + experiments: { + asyncWebAssembly: true, + }, + plugins: plugins, + }; + + /** + * @type {import("webpack").Configuration[]} + */ + const configs = []; + + if (manifestVersion == 2) { + mainConfig.optimization.splitChunks.cacheGroups.commons2 = { + test: /[\\/]node_modules[\\/]/, + name: "vendor", + chunks: (chunk) => { + return chunk.name === "background"; + }, + }; + + // Manifest V2 uses Background Pages which requires a html page. + mainConfig.plugins.push( + new HtmlWebpackPlugin({ + template: "./src/platform/background.html", + filename: "background.html", + chunks: ["vendor", "background"], + }), + ); + + // Manifest V2 background pages can be run through the regular build pipeline. + // Since it's a standard webpage. + mainConfig.entry.background = params.background.entry; + mainConfig.entry["content/fido2-page-script-delay-append-mv2"] = + "./src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts"; + + configs.push(mainConfig); + } else { + // Firefox does not use the offscreen API + if (browser !== "firefox") { + mainConfig.entry["offscreen-document/offscreen-document"] = + "./src/platform/offscreen-document/offscreen-document.ts"; + + mainConfig.plugins.push( + new HtmlWebpackPlugin({ + template: "./src/platform/offscreen-document/index.html", + filename: "offscreen-document/index.html", + chunks: ["offscreen-document/offscreen-document"], + }), + ); + } + + const target = browser === "firefox" ? "web" : "webworker"; + + /** + * @type {import("webpack").Configuration} + */ + const backgroundConfig = { + name: "background", + mode: ENV, + devtool: false, + entry: params.background.entry, + target: target, + output: { + filename: "background.js", + path: path.resolve(__dirname, "build"), + }, + module: { + rules: [ + { + test: /\.tsx?$/, + loader: "ts-loader", + }, + ], + }, + cache: + ENV !== "development" + ? false + : { + type: "filesystem", + name: "background-cache", + cacheDirectory: path.resolve( + __dirname, + "../../node_modules/.cache/webpack-browser-background", + ), + buildDependencies: { + config: [__filename], + }, + }, + snapshot: { + unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")], + }, + experiments: { + asyncWebAssembly: true, + }, + resolve: { + extensions: [".ts", ".js"], + symlinks: false, + modules: [path.resolve("../../node_modules")], + plugins: [new TsconfigPathsPlugin()], + fallback: { + fs: false, + path: require.resolve("path-browserify"), + }, + cache: true, + }, + dependencies: ["main"], + plugins: [...requiredPlugins, new AngularCheckPlugin()], + }; + + // Safari's desktop build process requires a background.html and vendor.js file to exist + // within the root of the extension. This is a workaround to allow us to build Safari + // for manifest v2 and v3 without modifying the desktop project structure. + if (browser === "safari") { + backgroundConfig.plugins.push( + new CopyWebpackPlugin({ + patterns: [ + { from: "./src/safari/mv3/fake-background.html", to: "background.html" }, + { from: "./src/safari/mv3/fake-vendor.js", to: "vendor.js" }, + ], + }), + ); + } + + configs.push(mainConfig); + configs.push(backgroundConfig); + } + + return configs; +}; diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index e62f90354d2..9eac990ab61 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -1,416 +1,13 @@ -const path = require("path"); -const webpack = require("webpack"); -const HtmlWebpackPlugin = require("html-webpack-plugin"); -const CopyWebpackPlugin = require("copy-webpack-plugin"); -const MiniCssExtractPlugin = require("mini-css-extract-plugin"); -const { AngularWebpackPlugin } = require("@ngtools/webpack"); -const TerserPlugin = require("terser-webpack-plugin"); -const { TsconfigPathsPlugin } = require("tsconfig-paths-webpack-plugin"); -const configurator = require("./config/config"); -const manifest = require("./webpack/manifest"); -const AngularCheckPlugin = require("./webpack/angular-check"); +const { buildConfig } = require("./webpack.base"); -if (process.env.NODE_ENV == null) { - process.env.NODE_ENV = "development"; -} -const ENV = (process.env.ENV = process.env.NODE_ENV); -const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2; -const browser = process.env.BROWSER ?? "chrome"; - -console.log(`Building Manifest Version ${manifestVersion} app`); - -const envConfig = configurator.load(ENV); -configurator.log(envConfig); - -const moduleRules = [ - { - test: /\.(html)$/, - loader: "html-loader", - }, - { - test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/, - exclude: /loading.svg/, - generator: { - filename: "popup/fonts/[name].[contenthash][ext]", - }, - type: "asset/resource", - }, - { - test: /\.(jpe?g|png|gif|svg)$/i, - exclude: /.*(bwi-font|glyphicons-halflings-regular)\.svg/, - generator: { - filename: "popup/images/[name][ext]", - }, - type: "asset/resource", - }, - { - test: /\.css$/, - use: [ - { - loader: MiniCssExtractPlugin.loader, - }, - "css-loader", - "resolve-url-loader", - { - loader: "postcss-loader", - options: { - sourceMap: true, - }, - }, - ], - }, - { - test: /\.scss$/, - use: [ - { - loader: MiniCssExtractPlugin.loader, - }, - "css-loader", - "resolve-url-loader", - { - loader: "sass-loader", - options: { - sourceMap: true, - }, - }, - ], - }, - { - test: /\.[cm]?js$/, - use: [ - { - loader: "babel-loader", - options: { - configFile: "../../babel.config.json", - cacheDirectory: ENV === "development", - compact: ENV !== "development", - }, - }, - ], - }, - { - test: /\.[jt]sx?$/, - loader: "@ngtools/webpack", - }, -]; - -const requiredPlugins = [ - new webpack.DefinePlugin({ - "process.env": { - ENV: JSON.stringify(ENV), - }, - }), - new webpack.EnvironmentPlugin({ - FLAGS: envConfig.flags, - DEV_FLAGS: ENV === "development" ? envConfig.devFlags : {}, - }), -]; - -const plugins = [ - new HtmlWebpackPlugin({ - template: "./src/popup/index.ejs", - filename: "popup/index.html", - chunks: ["popup/polyfills", "popup/vendor-angular", "popup/vendor", "popup/main"], - browser: browser, - }), - new HtmlWebpackPlugin({ - template: "./src/autofill/notification/bar.html", - filename: "notification/bar.html", - chunks: ["notification/bar"], - }), - new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/inline-menu/pages/button/button.html", - filename: "overlay/menu-button.html", - chunks: ["overlay/menu-button"], - }), - new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/inline-menu/pages/list/list.html", - filename: "overlay/menu-list.html", - chunks: ["overlay/menu-list"], - }), - new HtmlWebpackPlugin({ - template: "./src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html", - filename: "overlay/menu.html", - chunks: ["overlay/menu"], - }), - new CopyWebpackPlugin({ - patterns: [ - { - from: manifestVersion == 3 ? "./src/manifest.v3.json" : "./src/manifest.json", - to: "manifest.json", - transform: manifest.transform(browser), - }, - { from: "./src/managed_schema.json", to: "managed_schema.json" }, - { from: "./src/_locales", to: "_locales" }, - { from: "./src/images", to: "images" }, - { from: "./src/popup/images", to: "popup/images" }, - { from: "./src/autofill/content/autofill.css", to: "content" }, - ], - }), - new MiniCssExtractPlugin({ - filename: "[name].css", - chunkFilename: "chunk-[id].css", - }), - new AngularWebpackPlugin({ - tsConfigPath: "tsconfig.json", +module.exports = buildConfig({ + configName: "OSS", + popup: { + entry: "./src/popup/main.ts", entryModule: "src/popup/app.module#AppModule", - sourceMap: true, - }), - new webpack.ProvidePlugin({ - process: "process/browser.js", - }), - new webpack.SourceMapDevToolPlugin({ - exclude: [/content\/.*/, /notification\/.*/, /overlay\/.*/], - filename: "[file].map", - }), - ...requiredPlugins, -]; - -/** - * @type {import("webpack").Configuration} - * This config compiles everything but the background - */ -const mainConfig = { - name: "main", - mode: ENV, - devtool: false, - entry: { - "popup/polyfills": "./src/popup/polyfills.ts", - "popup/main": "./src/popup/main.ts", - "content/trigger-autofill-script-injection": - "./src/autofill/content/trigger-autofill-script-injection.ts", - "content/bootstrap-autofill": "./src/autofill/content/bootstrap-autofill.ts", - "content/bootstrap-autofill-overlay": "./src/autofill/content/bootstrap-autofill-overlay.ts", - "content/bootstrap-autofill-overlay-menu": - "./src/autofill/content/bootstrap-autofill-overlay-menu.ts", - "content/bootstrap-autofill-overlay-notifications": - "./src/autofill/content/bootstrap-autofill-overlay-notifications.ts", - "content/autofiller": "./src/autofill/content/autofiller.ts", - "content/auto-submit-login": "./src/autofill/content/auto-submit-login.ts", - "content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts", - "content/content-message-handler": "./src/autofill/content/content-message-handler.ts", - "content/fido2-content-script": "./src/autofill/fido2/content/fido2-content-script.ts", - "content/fido2-page-script": "./src/autofill/fido2/content/fido2-page-script.ts", - "content/ipc-content-script": "./src/platform/ipc/content/ipc-content-script.ts", - "notification/bar": "./src/autofill/notification/bar.ts", - "overlay/menu-button": - "./src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts", - "overlay/menu-list": - "./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts", - "overlay/menu": - "./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts", - "content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts", - "content/send-popup-open-message": "./src/vault/content/send-popup-open-message.ts", }, - cache: - ENV !== "development" - ? false - : { - type: "filesystem", - name: "main-cache", - cacheDirectory: path.resolve(__dirname, "../../node_modules/.cache/webpack-browser-main"), - buildDependencies: { - config: [__filename], - }, - }, - snapshot: { - unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")], - }, - optimization: { - minimize: ENV !== "development", - minimizer: [ - new TerserPlugin({ - exclude: [/content\/.*/, /notification\/.*/, /overlay\/.*/], - terserOptions: { - // Replicate Angular CLI behaviour - compress: { - global_defs: { - ngDevMode: false, - ngI18nClosureMode: false, - }, - }, - }, - }), - ], - splitChunks: { - cacheGroups: { - commons: { - test(module, chunks) { - return ( - module.resource != null && - module.resource.includes(`${path.sep}node_modules${path.sep}`) && - !module.resource.includes(`${path.sep}node_modules${path.sep}@angular${path.sep}`) - ); - }, - name: "popup/vendor", - chunks: (chunk) => { - return chunk.name === "popup/main"; - }, - }, - angular: { - test(module, chunks) { - return ( - module.resource != null && - module.resource.includes(`${path.sep}node_modules${path.sep}@angular${path.sep}`) - ); - }, - name: "popup/vendor-angular", - chunks: (chunk) => { - return chunk.name === "popup/main"; - }, - }, - }, - }, - }, - resolve: { - extensions: [".ts", ".js"], - symlinks: false, - modules: [path.resolve("../../node_modules")], - fallback: { - assert: false, - buffer: require.resolve("buffer/"), - util: require.resolve("util/"), - url: require.resolve("url/"), - fs: false, - path: require.resolve("path-browserify"), - }, - cache: true, - }, - output: { - filename: "[name].js", - chunkFilename: "assets/[name].js", - webassemblyModuleFilename: "assets/[modulehash].wasm", - path: path.resolve(__dirname, "build"), - clean: true, - }, - module: { - rules: moduleRules, - }, - experiments: { - asyncWebAssembly: true, - }, - plugins: plugins, -}; - -/** - * @type {import("webpack").Configuration[]} - */ -const configs = []; - -if (manifestVersion == 2) { - mainConfig.optimization.splitChunks.cacheGroups.commons2 = { - test: /[\\/]node_modules[\\/]/, - name: "vendor", - chunks: (chunk) => { - return chunk.name === "background"; - }, - }; - - // Manifest V2 uses Background Pages which requires a html page. - mainConfig.plugins.push( - new HtmlWebpackPlugin({ - template: "./src/platform/background.html", - filename: "background.html", - chunks: ["vendor", "background"], - }), - ); - - // Manifest V2 background pages can be run through the regular build pipeline. - // Since it's a standard webpage. - mainConfig.entry.background = "./src/platform/background.ts"; - mainConfig.entry["content/fido2-page-script-delay-append-mv2"] = - "./src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts"; - - configs.push(mainConfig); -} else { - // Firefox does not use the offscreen API - if (browser !== "firefox") { - mainConfig.entry["offscreen-document/offscreen-document"] = - "./src/platform/offscreen-document/offscreen-document.ts"; - - mainConfig.plugins.push( - new HtmlWebpackPlugin({ - template: "./src/platform/offscreen-document/index.html", - filename: "offscreen-document/index.html", - chunks: ["offscreen-document/offscreen-document"], - }), - ); - } - - const target = browser === "firefox" ? "web" : "webworker"; - - /** - * @type {import("webpack").Configuration} - */ - const backgroundConfig = { - name: "background", - mode: ENV, - devtool: false, + background: { entry: "./src/platform/background.ts", - target: target, - output: { - filename: "background.js", - path: path.resolve(__dirname, "build"), - }, - module: { - rules: [ - { - test: /\.tsx?$/, - loader: "ts-loader", - }, - ], - }, - cache: - ENV !== "development" - ? false - : { - type: "filesystem", - name: "background-cache", - cacheDirectory: path.resolve( - __dirname, - "../../node_modules/.cache/webpack-browser-background", - ), - buildDependencies: { - config: [__filename], - }, - }, - snapshot: { - unmanagedPaths: [path.resolve(__dirname, "../../node_modules/@bitwarden/")], - }, - experiments: { - asyncWebAssembly: true, - }, - resolve: { - extensions: [".ts", ".js"], - symlinks: false, - modules: [path.resolve("../../node_modules")], - plugins: [new TsconfigPathsPlugin()], - fallback: { - fs: false, - path: require.resolve("path-browserify"), - }, - cache: true, - }, - dependencies: ["main"], - plugins: [...requiredPlugins, new AngularCheckPlugin()], - }; - - // Safari's desktop build process requires a background.html and vendor.js file to exist - // within the root of the extension. This is a workaround to allow us to build Safari - // for manifest v2 and v3 without modifying the desktop project structure. - if (browser === "safari") { - backgroundConfig.plugins.push( - new CopyWebpackPlugin({ - patterns: [ - { from: "./src/safari/mv3/fake-background.html", to: "background.html" }, - { from: "./src/safari/mv3/fake-vendor.js", to: "vendor.js" }, - ], - }), - ); - } - - configs.push(mainConfig); - configs.push(backgroundConfig); -} - -module.exports = configs; + }, + tsConfig: "tsconfig.json", +}); diff --git a/apps/cli/src/auth/commands/login.command.ts b/apps/cli/src/auth/commands/login.command.ts index 79414784645..133c9658ae7 100644 --- a/apps/cli/src/auth/commands/login.command.ts +++ b/apps/cli/src/auth/commands/login.command.ts @@ -46,6 +46,7 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac import { KdfConfigService, KeyService } from "@bitwarden/key-management"; import { NodeUtils } from "@bitwarden/node/node-utils"; +import { ConfirmKeyConnectorDomainCommand } from "../../key-management/confirm-key-connector-domain.command"; import { Response } from "../../models/response"; import { MessageResponse } from "../../models/response/message.response"; @@ -332,6 +333,24 @@ export class LoginCommand { ); } + // Check if Key Connector domain confirmation is required + const domainConfirmation = await firstValueFrom( + this.keyConnectorService.requiresDomainConfirmation$(response.userId), + ); + if (domainConfirmation != null) { + const command = new ConfirmKeyConnectorDomainCommand( + response.userId, + domainConfirmation.keyConnectorUrl, + this.keyConnectorService, + this.logoutCallback, + this.i18nService, + ); + const confirmResponse = await command.run(); + if (!confirmResponse.success) { + return confirmResponse; + } + } + // Run full sync before handling success response or password reset flows (to get Master Password Policies) await this.syncService.fullSync(true, { skipTokenRefresh: true }); diff --git a/apps/cli/src/key-management/confirm-key-connector-domain.command.spec.ts b/apps/cli/src/key-management/confirm-key-connector-domain.command.spec.ts new file mode 100644 index 00000000000..7da0fbb35de --- /dev/null +++ b/apps/cli/src/key-management/confirm-key-connector-domain.command.spec.ts @@ -0,0 +1,153 @@ +import { createPromptModule } from "inquirer"; +import { mock } from "jest-mock-extended"; + +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { Response } from "../models/response"; +import { MessageResponse } from "../models/response/message.response"; +import { I18nService } from "../platform/services/i18n.service"; + +import { ConfirmKeyConnectorDomainCommand } from "./confirm-key-connector-domain.command"; + +jest.mock("inquirer", () => { + return { + createPromptModule: jest.fn(() => jest.fn(() => Promise.resolve({ confirm: "" }))), + }; +}); + +describe("ConfirmKeyConnectorDomainCommand", () => { + let command: ConfirmKeyConnectorDomainCommand; + + const userId = "test-user-id" as UserId; + const keyConnectorUrl = "https://keyconnector.example.com"; + + const keyConnectorService = mock(); + const logout = jest.fn(); + const i18nService = mock(); + + beforeEach(async () => { + command = new ConfirmKeyConnectorDomainCommand( + userId, + keyConnectorUrl, + keyConnectorService, + logout, + i18nService, + ); + + i18nService.t.mockImplementation((key: string) => { + switch (key) { + case "confirmKeyConnectorDomain": + return "Please confirm the domain below with your organization administrator. Key Connector domain: https://keyconnector.example.com"; + case "confirm": + return "Confirm"; + case "logOut": + return "Log out"; + case "youHaveBeenLoggedOut": + return "You have been logged out."; + case "organizationUsingKeyConnectorConfirmLoggedOut": + return "An organization you are a member of is using Key Connector. In order to access the vault, you must confirm the Key Connector domain now via the web vault. You have been logged out."; + default: + return ""; + } + }); + }); + + describe("run", () => { + it("should logout and return error response if no interaction available", async () => { + process.env.BW_NOINTERACTION = "true"; + + const response = await command.run(); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response).toEqual( + Response.error( + new MessageResponse( + "An organization you are a member of is using Key Connector. In order to access the vault, you must confirm the Key Connector domain now via the web vault. You have been logged out.", + null, + ), + ), + ); + expect(logout).toHaveBeenCalled(); + }); + + it("should logout and return error response if interaction answer is cancel", async () => { + process.env.BW_NOINTERACTION = "false"; + + (createPromptModule as jest.Mock).mockImplementation(() => + jest.fn((prompt) => { + assertPrompt(prompt); + return Promise.resolve({ confirm: "cancel" }); + }), + ); + + const response = await command.run(); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(false); + expect(response).toEqual(Response.error("You have been logged out.")); + expect(logout).toHaveBeenCalled(); + }); + + it("should convert new sso user to key connector and return success response if answer is confirmed", async () => { + process.env.BW_NOINTERACTION = "false"; + + (createPromptModule as jest.Mock).mockImplementation(() => + jest.fn((prompt) => { + assertPrompt(prompt); + return Promise.resolve({ confirm: "confirmed" }); + }), + ); + + const response = await command.run(); + + expect(response).not.toBeNull(); + expect(response.success).toEqual(true); + expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(userId); + }); + + it("should logout and throw error if convert new sso user to key connector failed", async () => { + process.env.BW_NOINTERACTION = "false"; + + (createPromptModule as jest.Mock).mockImplementation(() => + jest.fn((prompt) => { + assertPrompt(prompt); + return Promise.resolve({ confirm: "confirmed" }); + }), + ); + + keyConnectorService.convertNewSsoUserToKeyConnector.mockRejectedValue( + new Error("Migration failed"), + ); + + await expect(command.run()).rejects.toThrow("Migration failed"); + expect(logout).toHaveBeenCalled(); + }); + + function assertPrompt(prompt: unknown) { + expect(typeof prompt).toEqual("object"); + expect(prompt).toHaveProperty("type"); + expect(prompt).toHaveProperty("name"); + expect(prompt).toHaveProperty("message"); + expect(prompt).toHaveProperty("choices"); + const promptObj = prompt as Record; + expect(promptObj["type"]).toEqual("list"); + expect(promptObj["name"]).toEqual("confirm"); + expect(promptObj["message"]).toEqual( + `Please confirm the domain below with your organization administrator. Key Connector domain: ${keyConnectorUrl}`, + ); + expect(promptObj["choices"]).toBeInstanceOf(Array); + const choices = promptObj["choices"] as Array>; + expect(choices).toHaveLength(2); + expect(choices[0]).toEqual({ + name: "Confirm", + value: "confirmed", + }); + expect(choices[1]).toEqual({ + name: "Log out", + value: "cancel", + }); + } + }); +}); diff --git a/apps/cli/src/key-management/confirm-key-connector-domain.command.ts b/apps/cli/src/key-management/confirm-key-connector-domain.command.ts new file mode 100644 index 00000000000..da53f706d0d --- /dev/null +++ b/apps/cli/src/key-management/confirm-key-connector-domain.command.ts @@ -0,0 +1,62 @@ +import * as inquirer from "inquirer"; + +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { Response } from "../models/response"; +import { MessageResponse } from "../models/response/message.response"; + +export class ConfirmKeyConnectorDomainCommand { + constructor( + private readonly userId: UserId, + private readonly keyConnectorUrl: string, + private keyConnectorService: KeyConnectorService, + private logout: () => Promise, + private i18nService: I18nService, + ) {} + + async run(): Promise { + // If no interaction available, alert user to use web vault + const canInteract = process.env.BW_NOINTERACTION !== "true"; + if (!canInteract) { + await this.logout(); + return Response.error( + new MessageResponse( + this.i18nService.t("organizationUsingKeyConnectorConfirmLoggedOut"), + null, + ), + ); + } + + const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({ + type: "list", + name: "confirm", + message: this.i18nService.t("confirmKeyConnectorDomain", this.keyConnectorUrl), + choices: [ + { + name: this.i18nService.t("confirm"), + value: "confirmed", + }, + { + name: this.i18nService.t("logOut"), + value: "cancel", + }, + ], + }); + + if (answer.confirm === "confirmed") { + try { + await this.keyConnectorService.convertNewSsoUserToKeyConnector(this.userId); + } catch (e) { + await this.logout(); + throw e; + } + + return Response.success(); + } else { + await this.logout(); + return Response.error(this.i18nService.t("youHaveBeenLoggedOut")); + } + } +} diff --git a/apps/cli/src/locales/en/messages.json b/apps/cli/src/locales/en/messages.json index cb7f89781dd..4a8c774ea42 100644 --- a/apps/cli/src/locales/en/messages.json +++ b/apps/cli/src/locales/en/messages.json @@ -218,5 +218,20 @@ }, "myItems": { "message": "My Items" + }, + "organizationUsingKeyConnectorConfirmLoggedOut": { + "message": "An organization you are a member of is using Key Connector. In order to access the vault, you must confirm the Key Connector domain now via the web vault. You have been logged out." + }, + "confirmKeyConnectorDomain": { + "message": "Please confirm the domain below with your organization administrator. Key Connector domain: $KEYCONNECTORDOMAIN$", + "placeholders": { + "keyConnectorDomain": { + "content": "$1", + "example": "Key Connector domain" + } + } + }, + "confirm": { + "message": "Confirm" } } diff --git a/apps/cli/src/platform/services/node-api.service.ts b/apps/cli/src/platform/services/node-api.service.ts index d695272364b..e6527ed3abd 100644 --- a/apps/cli/src/platform/services/node-api.service.ts +++ b/apps/cli/src/platform/services/node-api.service.ts @@ -4,6 +4,7 @@ import * as FormData from "form-data"; import { HttpsProxyAgent } from "https-proxy-agent"; import * as fe from "node-fetch"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"; import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; @@ -28,6 +29,7 @@ export class NodeApiService extends ApiService { logService: LogService, logoutCallback: () => Promise, vaultTimeoutSettingsService: VaultTimeoutSettingsService, + accountService: AccountService, customUserAgent: string = null, ) { super( @@ -39,6 +41,7 @@ export class NodeApiService extends ApiService { logService, logoutCallback, vaultTimeoutSettingsService, + accountService, { createRequest: (url, request) => new Request(url, request) }, customUserAgent, ); diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index 508ade4650e..76eeb340550 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -113,10 +113,13 @@ import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal"; import { AuditService } from "@bitwarden/common/services/audit.service"; import { EventCollectionService } from "@bitwarden/common/services/event/event-collection.service"; import { EventUploadService } from "@bitwarden/common/services/event/event-upload.service"; +import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider"; +import { buildExtensionRegistry } from "@bitwarden/common/tools/extension/factory"; import { PasswordStrengthService, PasswordStrengthServiceAbstraction, } from "@bitwarden/common/tools/password-strength"; +import { createSystemServiceProvider } from "@bitwarden/common/tools/providers"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service"; import { SendStateProvider } from "@bitwarden/common/tools/send/services/send-state.provider"; import { SendService } from "@bitwarden/common/tools/send/services/send.service"; @@ -504,12 +507,13 @@ export class ServiceContainer { this.logService, logoutCallback, this.vaultTimeoutSettingsService, + this.accountService, customUserAgent, ); this.containerService = new ContainerService(this.keyService, this.encryptService); - this.configApiService = new ConfigApiService(this.apiService, this.tokenService); + this.configApiService = new ConfigApiService(this.apiService); this.authService = new AuthService( this.accountService, @@ -601,6 +605,7 @@ export class ServiceContainer { this.kdfConfigService, this.keyService, this.stateProvider, + this.configService, customUserAgent, ); @@ -814,8 +819,16 @@ export class ServiceContainer { this.encryptService, this.pinService, this.accountService, - this.sdkService, this.restrictedItemTypesService, + createSystemServiceProvider( + new KeyServiceLegacyEncryptorProvider(this.encryptService, this.keyService), + this.stateProvider, + this.policyService, + buildExtensionRegistry(), + this.logService, + this.platformUtilsService, + this.configService, + ), ); this.individualExportService = new IndividualVaultExportService( diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index 795a7f6461c..bc0e48f285d 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -447,6 +447,32 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "bitwarden_chromium_importer" +version = "0.0.0" +dependencies = [ + "aes", + "aes-gcm", + "anyhow", + "async-trait", + "base64", + "cbc", + "hex", + "homedir", + "log", + "oo7", + "pbkdf2", + "rand 0.9.1", + "rusqlite", + "security-framework", + "serde", + "serde_json", + "sha1", + "tokio", + "winapi", + "windows 0.61.1", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -586,9 +612,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.38" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" +checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" dependencies = [ "clap_builder", "clap_derive", @@ -596,9 +622,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.38" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" +checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" dependencies = [ "anstream", "anstyle", @@ -608,9 +634,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.32" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" dependencies = [ "heck", "proc-macro2", @@ -897,6 +923,7 @@ dependencies = [ "rsa", "russh-cryptovec", "scopeguard", + "secmem-proc", "security-framework", "security-framework-sys", "sha2", @@ -923,6 +950,7 @@ dependencies = [ "anyhow", "autotype", "base64", + "bitwarden_chromium_importer", "desktop_core", "hex", "log", @@ -1166,6 +1194,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1425,6 +1465,18 @@ name = "hashbrown" version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.3", +] [[package]] name = "heck" @@ -1690,6 +1742,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947e6816f7825b2b45027c2c32e7085da9934defa535de4a6a46b10a4d5257fa" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "link-cplusplus" version = "1.0.10" @@ -2643,6 +2706,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rusqlite" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22715a5d6deef63c637207afbe68d0c72c3f8d0022d7cf9714c442d6157606b" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "russh-cryptovec" version = "0.7.3" @@ -2694,6 +2771,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.0.7", +] + [[package]] name = "rustversion" version = "1.0.20" @@ -2772,6 +2859,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secmem-proc" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473559b1d28f530c3a9b5f91a2866053e2b1c528a0e43dae83048139c99490c2" +dependencies = [ + "anyhow", + "cfg-if", + "libc", + "rustix 1.0.7", + "rustix-linux-procfs", + "thiserror 2.0.12", + "windows 0.61.1", +] + [[package]] name = "security-framework" version = "3.1.0" @@ -2847,6 +2949,17 @@ dependencies = [ "syn", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.8" @@ -3179,6 +3292,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -3497,6 +3611,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/apps/desktop/desktop_native/Cargo.toml b/apps/desktop/desktop_native/Cargo.toml index 18499e1dcef..de344e0b4ca 100644 --- a/apps/desktop/desktop_native/Cargo.toml +++ b/apps/desktop/desktop_native/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "autotype", + "bitwarden_chromium_importer", "core", "macos_provider", "napi", @@ -21,7 +22,6 @@ anyhow = "=1.0.94" arboard = { version = "=3.6.0", default-features = false } ashpd = "=0.11.0" base64 = "=0.22.1" -bindgen = "=0.72.0" bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", rev = "a641316227227f8777fdf56ac9fa2d6b5f7fe662" } byteorder = "=1.5.0" bytes = "=1.10.1" @@ -49,6 +49,7 @@ rand = "=0.9.1" rsa = "=0.9.6" russh-cryptovec = "=0.7.3" scopeguard = "=1.2.0" +secmem-proc = "=0.3.7" security-framework = "=3.1.0" security-framework-sys = "=2.13.0" serde = "=1.0.209" diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml b/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml new file mode 100644 index 00000000000..8512ed1b319 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "bitwarden_chromium_importer" +edition = { workspace = true } +license = { workspace = true } +version = { workspace = true } +publish = { workspace = true } + +[dependencies] +aes = { workspace = true } +aes-gcm = "=0.10.3" +anyhow = { workspace = true } +async-trait = "=0.1.88" +base64 = { workspace = true } +cbc = { workspace = true, features = ["alloc"] } +hex = { workspace = true } +homedir = { workspace = true } +log = { workspace = true } +pbkdf2 = "=0.12.2" +rand = { workspace = true } +rusqlite = { version = "=0.35.0", features = ["bundled"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha1 = "=0.10.6" + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +tokio = { workspace = true, features = ["full"] } +winapi = { version = "=0.3.9", features = ["dpapi", "memoryapi"] } +windows = { workspace = true, features = ["Win32_Security", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_Services", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } + +[target.'cfg(target_os = "linux")'.dependencies] +oo7 = { workspace = true } + +[lints] +workspace = true + diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md b/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md new file mode 100644 index 00000000000..498dd3ac67d --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/README.md @@ -0,0 +1,156 @@ +# Windows ABE Architecture + +## Overview + +The Windows Application Bound Encryption (ABE) consists of three main components that work together: + +- **client library** -- Library that is part of the desktop client application +- **admin.exe** -- Service launcher running as ADMINISTRATOR +- **service.exe** -- Background Windows service running as SYSTEM + +_(The names of the binaries will be changed for the released product.)_ + +## The goal + +The goal of this subsystem is to decrypt the master encryption key with which the login information +is encrypted on the local system in Windows. This applies to the most recent versions of Chrome and +Edge (untested yet) that are using the ABE/v20 encryption scheme for some of the local profiles. + +The general idea of this encryption scheme is that Chrome generates a unique random encryption key, +then encrypts it at the user level with a fixed key. It then sends it to the Windows Data Protection +API at the user level, and then, using an installed service, encrypts it with the Windows Data +Protection API at the system level on top of that. This triply encrypted key is later stored in the +`Local State` file. + +The next paragraphs describe what is done at each level to decrypt the key. + +## 1. Client library + +This is a Rust module that is part of the Chromium importer. It only compiles and runs on Windows +(see `abe.rs` and `abe_config.rs`). Its main task is to launch `admin.exe` with elevated privileges +by presenting the user with the UAC screen. See the `abe::decrypt_with_admin_and_service` invocation +in `windows.rs`. + +This function takes three arguments: + +1. Absolute path to `admin.exe` +2. Absolute path to `service.exe` +3. Base64 string of the ABE key extracted from the browser's local state + +It's not possible to install the service from the user-level executable. So first, we have to +elevate the privileges and run `admin.exe` as ADMINISTRATOR. This is done by calling `ShellExecute` +with the `runas` verb. Since it's not trivial to read the standard output from an application +launched in this way, a named pipe server is created at the user level, which waits for the response +from `admin.exe` after it has been launched. + +The name of the service executable and the data to be decrypted are passed via the command line to +`admin.exe` like this: + +```bat +admin.exe --service-exe "c:\temp\service.exe" --encrypted "QVBQQgEAAADQjJ3fARXREYx6AMBPwpfrAQAAA..." +``` + +**At this point, the user must permit the action to be performed on the UAC screen.** + +## 2. Admin executable + +This executable receives the full path of `service.exe` and the data to be decrypted. + +First, it installs the service to run as SYSTEM and waits for it to start running. The service +creates a named pipe server that the admin-level executable communicates with (see the `service.exe` +description further down). + +It sends the base64 string to the pipe server in a raw message and waits for the answer. The answer +could be a success or a failure. In case of success, it's a base64 string decrypted at the system +level. In case of failure, it's an error message prefixed with an `!`. In either case, the response +is sent to the named pipe server created by the user. The user responds with `ok` (ignored). + +After that, the executable stops and uninstalls the service and then exits. + +## 3. System service + +The service starts and creates a named pipe server for communication between `admin.exe` and the +system service. Please note that it is not possible to communicate between the user and the system +service directly via a named pipe. Thus, this three-layered approach is necessary. + +Once the service is started, it waits for the incoming message via the named pipe. The expected +message is a base64 string to be decrypted. The data is decrypted via the Windows Data Protection +API `CryptUnprotectData` and sent back in response to this incoming message in base64 encoding. In +case of an error, the error message is sent back prefixed with an `!`. + +The service keeps running and servicing more requests if there are any, until it's stopped and +removed from the system. Even though we send only one request, the service is designed to handle as +many clients with as many messages as needed and could be installed on the system permanently if +necessary. + +## 4. Back to client library + +The decrypted base64-encoded string comes back from the admin executable to the named pipe server at +the user level. At this point, it has been decrypted only once at the system level. + +In the next step, the string is decrypted at the user level with the same Windows Data Protection +API. + +And as the third step, it's decrypted with a hard-coded key found in the `elevation_service.exe` +from the Chrome installation. Based on the version of the encrypted string (encoded in the string +itself), it's either AES-256-GCM or ChaCha20Poly1305 encryption scheme. The details can be found in +`windows.rs`. + +After all of these steps, we have the master key which can be used to decrypt the password +information stored in the local database. + +## Summary + +The Windows ABE decryption process involves a three-tier architecture with named pipe communication: + +```mermaid +sequenceDiagram + participant Client as Client Library (User) + participant Admin as admin.exe (Administrator) + participant Service as service.exe (System) + + Client->>Client: Create named pipe server + Note over Client: \\.\pipe\BitwardenEncryptionService-admin-user + + Client->>Admin: Launch with UAC elevation + Note over Client,Admin: --service-exe c:\path\to\service.exe + Note over Client,Admin: --encrypted QVBQQgEAAADQjJ3fARXRE... + + Client->>Client: Wait for response + + Admin->>Service: Install & start service + Note over Admin,Service: c:\path\to\service.exe + + Service->>Service: Create named pipe server + Note over Service: \\.\pipe\BitwardenEncryptionService-service-admin + + Service->>Service: Wait for message + + Admin->>Service: Send encrypted data via admin-service pipe + Note over Admin,Service: QVBQQgEAAADQjJ3fARXRE... + + Admin->>Admin: Wait for response + + Service->>Service: Decrypt with system-level DPAPI + + Service->>Admin: Return decrypted data via admin-service pipe + Note over Service,Admin: EjRWeXN0ZW0gU2VydmljZQ... + + Admin->>Client: Send result via named user-admin pipe + Note over Client,Admin: EjRWeXN0ZW0gU2VydmljZQ... + + Client->>Admin: Send ACK to admin + Note over Client,Admin: ok + + Admin->>Service: Stop & uninstall service + Service-->>Admin: Exit + + Admin-->>Client: Exit + + Client->>Client: Decrypt with user-level DPAPI + + Client->>Client: Decrypt with hardcoded key + Note over Client: AES-256-GCM or ChaCha20Poly1305 + + Client->>Client: Done +``` diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs new file mode 100644 index 00000000000..8179a10213d --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/chromium.rs @@ -0,0 +1,350 @@ +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use hex::decode; +use homedir::my_home; +use rusqlite::{params, Connection}; + +// Platform-specific code +#[cfg_attr(target_os = "linux", path = "linux.rs")] +#[cfg_attr(target_os = "windows", path = "windows.rs")] +#[cfg_attr(target_os = "macos", path = "macos.rs")] +mod platform; + +// +// Public API +// + +#[derive(Debug)] +pub struct ProfileInfo { + pub name: String, + pub folder: String, + + #[allow(dead_code)] + pub account_name: Option, + + #[allow(dead_code)] + pub account_email: Option, +} + +#[derive(Debug)] +pub struct Login { + pub url: String, + pub username: String, + pub password: String, + pub note: String, +} + +#[derive(Debug)] +pub struct LoginImportFailure { + pub url: String, + pub username: String, + pub error: String, +} + +#[derive(Debug)] +pub enum LoginImportResult { + Success(Login), + Failure(LoginImportFailure), +} + +// TODO: Make thus async +pub fn get_installed_browsers() -> Result> { + let mut browsers = Vec::with_capacity(SUPPORTED_BROWSER_MAP.len()); + + for (browser, config) in SUPPORTED_BROWSER_MAP.iter() { + let data_dir = get_browser_data_dir(config)?; + if data_dir.exists() { + browsers.push((*browser).to_string()); + } + } + + Ok(browsers) +} + +// TODO: Make thus async +pub fn get_available_profiles(browser_name: &String) -> Result> { + let (_, local_state) = load_local_state_for_browser(browser_name)?; + Ok(get_profile_info(&local_state)) +} + +pub async fn import_logins( + browser_name: &String, + profile_id: &String, +) -> Result> { + let (data_dir, local_state) = load_local_state_for_browser(browser_name)?; + + let mut crypto_service = platform::get_crypto_service(browser_name, &local_state) + .map_err(|e| anyhow!("Failed to get crypto service: {}", e))?; + + 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. + 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. + let all_logins = local_logins + .into_iter() + .chain(account_logins.into_iter()) + .collect::>(); + + let results = decrypt_logins(all_logins, &mut crypto_service).await; + + Ok(results) +} + +// +// Private +// + +#[derive(Debug)] +struct BrowserConfig { + name: &'static str, + data_dir: &'static str, +} + +static SUPPORTED_BROWSER_MAP: LazyLock< + std::collections::HashMap<&'static str, &'static BrowserConfig>, +> = LazyLock::new(|| { + platform::SUPPORTED_BROWSERS + .iter() + .map(|b| (b.name, b)) + .collect::>() +}); + +fn get_browser_data_dir(config: &BrowserConfig) -> Result { + let dir = my_home() + .map_err(|_| anyhow!("Home directory not found"))? + .ok_or_else(|| anyhow!("Home directory not found"))? + .join(config.data_dir); + Ok(dir) +} + +// +// CryptoService +// + +#[async_trait] +trait CryptoService: Send { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result; +} + +#[derive(serde::Deserialize, Clone)] +struct LocalState { + profile: AllProfiles, + #[allow(dead_code)] + os_crypt: Option, +} + +#[derive(serde::Deserialize, Clone)] +struct AllProfiles { + info_cache: std::collections::HashMap, +} + +#[derive(serde::Deserialize, Clone)] +struct OneProfile { + name: String, + gaia_name: Option, + user_name: Option, +} + +#[derive(serde::Deserialize, Clone)] +struct OsCrypt { + #[allow(dead_code)] + encrypted_key: Option, + #[allow(dead_code)] + app_bound_encrypted_key: Option, +} + +fn load_local_state_for_browser(browser_name: &String) -> Result<(PathBuf, LocalState)> { + let config = SUPPORTED_BROWSER_MAP + .get(browser_name.as_str()) + .ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?; + + let data_dir = get_browser_data_dir(config)?; + if !data_dir.exists() { + return Err(anyhow!( + "Browser user data directory '{}' not found", + data_dir.display() + )); + } + + let local_state = load_local_state(&data_dir)?; + + Ok((data_dir, local_state)) +} + +fn load_local_state(browser_dir: &Path) -> Result { + let local_state = std::fs::read_to_string(browser_dir.join("Local State")) + .map_err(|e| anyhow!("Failed to read local state file: {}", e))?; + + serde_json::from_str(&local_state) + .map_err(|e| anyhow!("Failed to parse local state JSON: {}", e)) +} + +fn get_profile_info(local_state: &LocalState) -> Vec { + let mut profile_infos = Vec::new(); + for (name, info) in local_state.profile.info_cache.iter() { + profile_infos.push(ProfileInfo { + name: info.name.clone(), + folder: name.clone(), + account_name: info.gaia_name.clone(), + account_email: info.user_name.clone(), + }); + } + profile_infos +} + +struct EncryptedLogin { + url: String, + username: String, + encrypted_password: Vec, + encrypted_note: Vec, +} + +fn get_logins( + browser_dir: &Path, + profile_id: &String, + filename: &str, +) -> Result> { + let login_data_path = browser_dir.join(profile_id).join(filename); + + // Sometimes database files are not present, so nothing to import + if !login_data_path.exists() { + return Ok(vec![]); + } + + // When the browser with the current profile is open the database file is locked. + // To access it we need to copy it to a temporary location. + let tmp_db_path = std::env::temp_dir().join(format!( + "tmp-logins-{}-{}.db", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| anyhow!("Failed to retrieve system time: {}", e))? + .as_millis(), + rand::random::() + )); + + std::fs::copy(&login_data_path, &tmp_db_path).map_err(|e| { + anyhow!( + "Failed to copy the password database file at {:?}: {}", + login_data_path, + e + ) + })?; + + let tmp_db_path = tmp_db_path + .to_str() + .ok_or_else(|| anyhow!("Failed to locate database."))?; + let maybe_logins = + query_logins(tmp_db_path).map_err(|e| anyhow!("Failed to query logins: {}", e))?; + + // Clean up temp file + let _ = std::fs::remove_file(tmp_db_path); + + Ok(maybe_logins) +} + +fn hex_to_bytes(hex: &str) -> Vec { + decode(hex).unwrap_or_default() +} + +fn does_table_exist(conn: &Connection, table_name: &str) -> Result { + let mut stmt = conn.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?1")?; + let exists = stmt.exists(params![table_name])?; + Ok(exists) +} + +fn query_logins(db_path: &str) -> Result, rusqlite::Error> { + let conn = Connection::open(db_path)?; + + let have_logins = does_table_exist(&conn, "logins")?; + let have_password_notes = does_table_exist(&conn, "password_notes")?; + if !have_logins || !have_password_notes { + return Ok(vec![]); + } + + let mut stmt = conn.prepare( + r#" + SELECT + l.origin_url AS url, + l.username_value AS username, + hex(l.password_value) AS encryptedPasswordHex, + hex(pn.value) AS encryptedNoteHex + FROM + logins l + LEFT JOIN + password_notes pn ON l.id = pn.parent_id + WHERE + l.blacklisted_by_user = 0 + "#, + )?; + + let logins_iter = stmt.query_map((), |row| { + let url: String = row.get("url")?; + let username: String = row.get("username")?; + let encrypted_password_hex: String = row.get("encryptedPasswordHex")?; + let encrypted_note_hex: String = row.get("encryptedNoteHex")?; + Ok(EncryptedLogin { + url, + username, + encrypted_password: hex_to_bytes(&encrypted_password_hex), + encrypted_note: hex_to_bytes(&encrypted_note_hex), + }) + })?; + + let mut logins = Vec::new(); + for login in logins_iter { + logins.push(login?); + } + + Ok(logins) +} + +async fn decrypt_logins( + encrypted_logins: Vec, + crypto_service: &mut Box, +) -> Vec { + let mut results = Vec::with_capacity(encrypted_logins.len()); + for encrypted_login in encrypted_logins { + let result = decrypt_login(encrypted_login, crypto_service).await; + results.push(result); + } + results +} + +async fn decrypt_login( + encrypted_login: EncryptedLogin, + crypto_service: &mut Box, +) -> LoginImportResult { + let maybe_password = crypto_service + .decrypt_to_string(&encrypted_login.encrypted_password) + .await; + match maybe_password { + Ok(password) => { + let note = crypto_service + .decrypt_to_string(&encrypted_login.encrypted_note) + .await + .unwrap_or_default(); + + LoginImportResult::Success(Login { + url: encrypted_login.url, + username: encrypted_login.username, + password, + note, + }) + } + Err(e) => LoginImportResult::Failure(LoginImportFailure { + url: encrypted_login.url, + username: encrypted_login.username, + error: e.to_string(), + }), + } +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs new file mode 100644 index 00000000000..a2b87d758a4 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/crypto.rs @@ -0,0 +1,17 @@ +//! Cryptographic primitives used in the SDK + +use anyhow::{Result, anyhow}; + +use aes::cipher::{ + block_padding::Pkcs7, generic_array::GenericArray, typenum::U32, BlockDecryptMut, KeyIvInit, +}; + +pub fn decrypt_aes256(iv: &[u8; 16], data: &[u8], key: GenericArray) -> Result> { + let iv = GenericArray::from_slice(iv); + let mut data = data.to_vec(); + return cbc::Decryptor::::new(&key, iv) + .decrypt_padded_mut::(&mut data) + .map_err(|_| anyhow!("Failed to decrypt data"))?; + + Ok(data) +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs new file mode 100644 index 00000000000..b0a399d6321 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/lib.rs @@ -0,0 +1 @@ +pub mod chromium; diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs new file mode 100644 index 00000000000..0ead034a4b2 --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/linux.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; + +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use oo7::XDG_SCHEMA_ATTRIBUTE; + +use crate::chromium::{BrowserConfig, CryptoService, LocalState}; + +mod 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.). +pub const SUPPORTED_BROWSERS: [BrowserConfig; 4] = [ + BrowserConfig { + name: "Chrome", + data_dir: ".config/google-chrome", + }, + BrowserConfig { + name: "Chromium", + data_dir: "snap/chromium/common/chromium", + }, + BrowserConfig { + name: "Brave", + data_dir: "snap/brave/current/.config/BraveSoftware/Brave-Browser", + }, + BrowserConfig { + name: "Opera", + data_dir: "snap/opera/current/.config/opera", + }, +]; + +pub fn get_crypto_service( + browser_name: &String, + _local_state: &LocalState, +) -> Result> { + let config = KEYRING_CONFIG + .iter() + .find(|b| b.browser == browser_name) + .ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?; + let service = LinuxCryptoService::new(config); + Ok(Box::new(service)) +} + +// +// Private +// + +#[derive(Debug)] +struct KeyringConfig { + browser: &'static str, + application_id: &'static str, +} + +const KEYRING_CONFIG: [KeyringConfig; SUPPORTED_BROWSERS.len()] = [ + KeyringConfig { + browser: "Chrome", + application_id: "chrome", + }, + KeyringConfig { + browser: "Chromium", + application_id: "chromium", + }, + KeyringConfig { + browser: "Brave", + application_id: "brave", + }, + KeyringConfig { + browser: "Opera", + application_id: "opera", + }, +]; + +const IV: [u8; 16] = [0x20; 16]; +const V10_KEY: [u8; 16] = [ + 0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27, 0x78, +]; + +struct LinuxCryptoService { + config: &'static KeyringConfig, + v11_key: Option>, +} + +impl LinuxCryptoService { + fn new(config: &'static KeyringConfig) -> Self { + Self { + config, + v11_key: None, + } + } + + fn decrypt_v10(&self, encrypted: &[u8]) -> Result { + decrypt(&V10_KEY, encrypted) + } + + async fn decrypt_v11(&mut self, encrypted: &[u8]) -> Result { + if self.v11_key.is_none() { + let master_password = get_master_password(self.config.application_id).await?; + self.v11_key = Some(util::derive_saltysalt(&master_password, 1)?); + } + + let key = self + .v11_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + decrypt(key, encrypted) + } +} + +#[async_trait] +impl CryptoService for LinuxCryptoService { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { + let (version, password) = + util::split_encrypted_string_and_validate(encrypted, &["v10", "v11"])?; + + let result = match version { + "v10" => self.decrypt_v10(password), + "v11" => self.decrypt_v11(password).await, + _ => Err(anyhow!("Logic error: unreachable code")), + }?; + + Ok(result) + } +} + +fn decrypt(key: &[u8], encrypted: &[u8]) -> Result { + let plaintext = util::decrypt_aes_128_cbc(key, &IV, encrypted)?; + String::from_utf8(plaintext).map_err(|e| anyhow!("UTF-8 error: {:?}", e)) +} + +async fn get_master_password(application_tag: &str) -> Result> { + let keyring = oo7::Keyring::new().await?; + keyring.unlock().await?; + + let attributes = HashMap::from([ + ( + XDG_SCHEMA_ATTRIBUTE, + "chrome_libsecret_os_crypt_password_v2", + ), + ("application", application_tag), + ]); + + let results = keyring.search_items(&attributes).await?; + match results.first() { + Some(r) => { + let secret = r.secret().await?; + Ok(secret.to_vec()) + } + None => Err(anyhow!("The master password not found in the keyring")), + } +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs new file mode 100644 index 00000000000..d9aeff68f2b --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/macos.rs @@ -0,0 +1,164 @@ +use anyhow::{anyhow, Result}; +use async_trait::async_trait; +use security_framework::passwords::get_generic_password; + +use crate::chromium::{BrowserConfig, CryptoService, LocalState}; + +mod util; + +// +// Public API +// + +pub const SUPPORTED_BROWSERS: [BrowserConfig; 7] = [ + BrowserConfig { + name: "Chrome", + data_dir: "Library/Application Support/Google/Chrome", + }, + BrowserConfig { + name: "Chromium", + data_dir: "Library/Application Support/Chromium", + }, + BrowserConfig { + name: "Microsoft Edge", + data_dir: "Library/Application Support/Microsoft Edge", + }, + BrowserConfig { + name: "Brave", + data_dir: "Library/Application Support/BraveSoftware/Brave-Browser", + }, + BrowserConfig { + name: "Arc", + data_dir: "Library/Application Support/Arc/User Data", + }, + BrowserConfig { + name: "Opera", + data_dir: "Library/Application Support/com.operasoftware.Opera", + }, + BrowserConfig { + name: "Vivaldi", + data_dir: "Library/Application Support/Vivaldi", + }, +]; + +pub fn get_crypto_service( + browser_name: &String, + _local_state: &LocalState, +) -> Result> { + let config = KEYCHAIN_CONFIG + .iter() + .find(|b| b.browser == browser_name) + .ok_or_else(|| anyhow!("Unsupported browser: {}", browser_name))?; + + Ok(Box::new(MacCryptoService::new(config))) +} + +// +// Private +// + +#[derive(Debug)] +struct KeychainConfig { + browser: &'static str, + service: &'static str, + account: &'static str, +} + +const KEYCHAIN_CONFIG: [KeychainConfig; SUPPORTED_BROWSERS.len()] = [ + KeychainConfig { + browser: "Chrome", + service: "Chrome Safe Storage", + account: "Chrome", + }, + KeychainConfig { + browser: "Chromium", + service: "Chromium Safe Storage", + account: "Chromium", + }, + KeychainConfig { + browser: "Microsoft Edge", + service: "Microsoft Edge Safe Storage", + account: "Microsoft Edge", + }, + KeychainConfig { + browser: "Brave", + service: "Brave Safe Storage", + account: "Brave", + }, + KeychainConfig { + browser: "Arc", + service: "Arc Safe Storage", + account: "Arc", + }, + KeychainConfig { + browser: "Opera", + service: "Opera Safe Storage", + account: "Opera", + }, + KeychainConfig { + browser: "Vivaldi", + service: "Vivaldi Safe Storage", + account: "Vivaldi", + }, +]; + +const IV: [u8; 16] = [0x20; 16]; // 16 bytes of 0x20 (space character) + +// +// CryptoService +// + +struct MacCryptoService { + config: &'static KeychainConfig, + master_key: Option>, +} + +impl MacCryptoService { + fn new(config: &'static KeychainConfig) -> Self { + Self { + config, + master_key: None, + } + } +} + +#[async_trait] +impl CryptoService for MacCryptoService { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { + if encrypted.is_empty() { + return Ok(String::new()); + } + + // On macOS only v10 is supported + let (_, no_prefix) = util::split_encrypted_string_and_validate(encrypted, &["v10"])?; + + // This might bring up the admin password prompt + if self.master_key.is_none() { + self.master_key = Some(get_master_key(self.config.service, self.config.account)?); + } + + let key = self + .master_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let plaintext = util::decrypt_aes_128_cbc(key, &IV, no_prefix) + .map_err(|e| anyhow!("Failed to decrypt: {}", e))?; + let plaintext = + String::from_utf8(plaintext).map_err(|e| anyhow!("Invalid UTF-8: {}", e))?; + + Ok(plaintext) + } +} + +fn get_master_key(service: &str, account: &str) -> Result> { + let master_password = get_master_password(service, account)?; + let key = util::derive_saltysalt(&master_password, 1003)?; + Ok(key) +} + +fn get_master_password(service: &str, account: &str) -> Result> { + let password = get_generic_password(service, account) + .map_err(|e| anyhow!("Failed to get password from keychain: {}", e))?; + + Ok(password) +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs new file mode 100644 index 00000000000..5edd4a2610f --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/util.rs @@ -0,0 +1,43 @@ +use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; +use anyhow::{anyhow, Result}; +use pbkdf2::{hmac::Hmac, pbkdf2}; +use sha1::Sha1; + +pub fn split_encrypted_string(encrypted: &[u8]) -> Result<(&str, &[u8])> { + if encrypted.len() < 3 { + return Err(anyhow!( + "Corrupted entry: invalid encrypted string length, expected at least 3 bytes, got {}", + encrypted.len() + )); + } + + let (version, password) = encrypted.split_at(3); + Ok((std::str::from_utf8(version)?, password)) +} + +pub fn split_encrypted_string_and_validate<'a>( + encrypted: &'a [u8], + supported_versions: &[&str], +) -> Result<(&'a str, &'a [u8])> { + let (version, password) = split_encrypted_string(encrypted)?; + if !supported_versions.contains(&version) { + return Err(anyhow!("Unsupported encryption version: {}", version)); + } + + Ok((version, password)) +} + +pub fn decrypt_aes_128_cbc(key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result> { + let decryptor = cbc::Decryptor::::new_from_slices(key, iv)?; + let plaintext = decryptor + .decrypt_padded_vec_mut::(ciphertext) + .map_err(|e| anyhow!("Failed to decrypt: {}", e))?; + Ok(plaintext) +} + +pub fn derive_saltysalt(password: &[u8], iterations: u32) -> Result> { + let mut key = vec![0u8; 16]; + pbkdf2::>(password, b"saltysalt", iterations, &mut key) + .map_err(|e| anyhow!("Failed to derive master key: {}", e))?; + Ok(key) +} diff --git a/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs new file mode 100644 index 00000000000..e7dffe93dba --- /dev/null +++ b/apps/desktop/desktop_native/bitwarden_chromium_importer/src/windows.rs @@ -0,0 +1,205 @@ +use aes_gcm::aead::Aead; +use aes_gcm::{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 winapi::shared::minwindef::{BOOL, BYTE, DWORD}; +use winapi::um::{dpapi::CryptUnprotectData, wincrypt::DATA_BLOB}; +use windows::Win32::Foundation::{LocalFree, HLOCAL}; + +use crate::chromium::{BrowserConfig, CryptoService, LocalState}; + +#[allow(dead_code)] +mod util; + +// +// Public API +// + +pub const SUPPORTED_BROWSERS: [BrowserConfig; 6] = [ + BrowserConfig { + name: "Chrome", + data_dir: "AppData/Local/Google/Chrome/User Data", + }, + BrowserConfig { + name: "Chromium", + data_dir: "AppData/Local/Chromium/User Data", + }, + BrowserConfig { + name: "Microsoft Edge", + data_dir: "AppData/Local/Microsoft/Edge/User Data", + }, + BrowserConfig { + name: "Brave", + data_dir: "AppData/Local/BraveSoftware/Brave-Browser/User Data", + }, + BrowserConfig { + name: "Opera", + data_dir: "AppData/Roaming/Opera Software/Opera Stable", + }, + BrowserConfig { + name: "Vivaldi", + data_dir: "AppData/Local/Vivaldi/User Data", + }, +]; + +pub fn get_crypto_service( + _browser_name: &str, + local_state: &LocalState, +) -> Result> { + Ok(Box::new(WindowsCryptoService::new(local_state))) +} + +// +// CryptoService +// +struct WindowsCryptoService { + master_key: Option>, + encrypted_key: Option, +} + +impl WindowsCryptoService { + pub(crate) fn new(local_state: &LocalState) -> Self { + Self { + master_key: None, + encrypted_key: local_state + .os_crypt + .as_ref() + .and_then(|c| c.encrypted_key.clone()), + } + } +} + +#[async_trait] +impl CryptoService for WindowsCryptoService { + async fn decrypt_to_string(&mut self, encrypted: &[u8]) -> Result { + if encrypted.is_empty() { + return Ok(String::new()); + } + + // On Windows only v10 and v20 are supported at the moment + 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] + const IV_SIZE: usize = 12; + const TAG_SIZE: usize = 16; + const MIN_LENGTH: usize = IV_SIZE + TAG_SIZE; + + if no_prefix.len() < MIN_LENGTH { + return Err(anyhow!( + "Corrupted entry: expected at least {} bytes, got {} bytes", + MIN_LENGTH, + no_prefix.len() + )); + } + + // Allow empty passwords + if no_prefix.len() == MIN_LENGTH { + return Ok(String::new()); + } + + if self.master_key.is_none() { + self.master_key = Some(self.get_master_key(version)?); + } + + let key = self + .master_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let key = Key::::from_slice(key); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(&no_prefix[..IV_SIZE]); + + let decrypted_bytes = cipher + .decrypt(nonce, no_prefix[IV_SIZE..].as_ref()) + .map_err(|e| anyhow!("Decryption failed: {}", e))?; + + let plaintext = String::from_utf8(decrypted_bytes) + .map_err(|e| anyhow!("Failed to convert decrypted data to UTF-8: {}", e))?; + + Ok(plaintext) + } +} + +impl WindowsCryptoService { + fn get_master_key(&mut self, version: &str) -> Result> { + match version { + "v10" => self.get_master_key_v10(), + _ => Err(anyhow!("Unsupported version: {}", version)), + } + } + + fn get_master_key_v10(&mut self) -> Result> { + if self.encrypted_key.is_none() { + return Err(anyhow!( + "Encrypted master key is not found in the local browser state" + )); + } + + let key = self + .encrypted_key + .as_ref() + .ok_or_else(|| anyhow!("Failed to retrieve key"))?; + let key_bytes = BASE64_STANDARD + .decode(key) + .map_err(|e| anyhow!("Encrypted master key is not a valid base64 string: {}", e))?; + + if key_bytes.len() <= 5 || &key_bytes[..5] != b"DPAPI" { + return Err(anyhow!("Encrypted master key is not encrypted with DPAPI")); + } + + let key = unprotect_data_win(&key_bytes[5..]) + .map_err(|e| anyhow!("Failed to unprotect the master key: {}", e))?; + + Ok(key) + } +} + +fn unprotect_data_win(data: &[u8]) -> Result> { + if data.is_empty() { + return Ok(Vec::new()); + } + + let mut data_in = DATA_BLOB { + cbData: data.len() as DWORD, + pbData: data.as_ptr() as *mut BYTE, + }; + + let mut data_out = DATA_BLOB { + cbData: 0, + pbData: std::ptr::null_mut(), + }; + + let result: BOOL = unsafe { + // BOOL from winapi (i32) + CryptUnprotectData( + &mut data_in, + std::ptr::null_mut(), // ppszDataDescr: *mut LPWSTR (*mut *mut u16) + std::ptr::null_mut(), // pOptionalEntropy: *mut DATA_BLOB + std::ptr::null_mut(), // pvReserved: LPVOID (*mut c_void) + std::ptr::null_mut(), // pPromptStruct: *mut CRYPTPROTECT_PROMPTSTRUCT + 0, // dwFlags: DWORD + &mut data_out, + ) + }; + + if result == 0 { + return Err(anyhow!("CryptUnprotectData failed")); + } + + if data_out.pbData.is_null() || data_out.cbData == 0 { + return Ok(Vec::new()); + } + + let output_slice = + unsafe { std::slice::from_raw_parts(data_out.pbData, data_out.cbData as usize) }; + + unsafe { + if !data_out.pbData.is_null() { + LocalFree(Some(HLOCAL(data_out.pbData as *mut std::ffi::c_void))); + } + } + + Ok(output_slice.to_vec()) +} diff --git a/apps/desktop/desktop_native/build.js b/apps/desktop/desktop_native/build.js index 2edd0e89616..125cb1bb567 100644 --- a/apps/desktop/desktop_native/build.js +++ b/apps/desktop/desktop_native/build.js @@ -35,7 +35,7 @@ function buildProxyBin(target, release = true) { const targetArg = target ? `--target ${target}` : ""; const releaseArg = release ? "--release" : ""; child_process.execSync(`cargo build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")}); - + if (target) { // Copy the resulting binary to the dist folder const targetFolder = release ? "release" : "debug"; diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index 79ffc6471e1..339cb674b4e 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -39,6 +39,7 @@ rand = { workspace = true } rsa = { workspace = true } russh-cryptovec = { workspace = true } scopeguard = { workspace = true } +secmem-proc = { workspace = true } sha2 = { workspace = true } ssh-encoding = { workspace = true } ssh-key = { workspace = true, features = [ 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 dc027e0b546..395d722ea01 100644 --- a/apps/desktop/desktop_native/core/src/process_isolation/linux.rs +++ b/apps/desktop/desktop_native/core/src/process_isolation/linux.rs @@ -20,6 +20,8 @@ pub fn disable_coredumps() -> Result<()> { rlim_cur: 0, rlim_max: 0, }; + println!("[Process Isolation] Disabling core dumps via setrlimit"); + if unsafe { libc::setrlimit(RLIMIT_CORE, &rlimit) } != 0 { let e = std::io::Error::last_os_error(); return Err(anyhow::anyhow!( @@ -44,11 +46,17 @@ pub fn is_core_dumping_disabled() -> Result { Ok(rlimit.rlim_cur == 0 && rlimit.rlim_max == 0) } -pub fn disable_memory_access() -> Result<()> { +pub fn isolate_process() -> Result<()> { + let pid = std::process::id(); + println!( + "[Process Isolation] Disabling ptrace and memory access for main ({}) via PR_SET_DUMPABLE", + pid + ); + if unsafe { libc::prctl(PR_SET_DUMPABLE, 0) } != 0 { let e = std::io::Error::last_os_error(); return Err(anyhow::anyhow!( - "failed to disable memory dumping, memory is dumpable by other processes {}", + "failed to disable memory dumping, memory may be accessible by other processes {}", e )); } diff --git a/apps/desktop/desktop_native/core/src/process_isolation/macos.rs b/apps/desktop/desktop_native/core/src/process_isolation/macos.rs index 04d8f7266c4..ce42e06a832 100644 --- a/apps/desktop/desktop_native/core/src/process_isolation/macos.rs +++ b/apps/desktop/desktop_native/core/src/process_isolation/macos.rs @@ -8,6 +8,17 @@ pub fn is_core_dumping_disabled() -> Result { bail!("Not implemented on Mac") } -pub fn disable_memory_access() -> Result<()> { - bail!("Not implemented on Mac") +pub fn isolate_process() -> Result<()> { + let pid: u32 = std::process::id(); + println!( + "[Process Isolation] Disabling ptrace on main process ({}) via PT_DENY_ATTACH", + pid + ); + + secmem_proc::harden_process().map_err(|e| { + anyhow::anyhow!( + "failed to disable memory dumping, memory may be accessible by other processes {}", + e + ) + }) } diff --git a/apps/desktop/desktop_native/core/src/process_isolation/mod.rs b/apps/desktop/desktop_native/core/src/process_isolation/mod.rs index 30f4dbf689a..b1872c8a423 100644 --- a/apps/desktop/desktop_native/core/src/process_isolation/mod.rs +++ b/apps/desktop/desktop_native/core/src/process_isolation/mod.rs @@ -1,3 +1,16 @@ +//! This module implements process isolation, which aims to protect +//! a process from dumping memory to disk when crashing, and from +//! userspace memory access. +//! +//! On Windows, by default most userspace apps can read the memory of all +//! other apps, and attach debuggers. On Mac, this is not possible, and only +//! after granting developer permissions can an app attach to processes via +//! ptrace / read memory. On Linux, this depends on the distro / configuration of yama +//! `https://linux-audit.com/protect-ptrace-processes-kernel-yama-ptrace_scope/` +//! For instance, ubuntu prevents ptrace of other processes by default. +//! On Fedora, there are change proposals but ptracing is still possible unless +//! otherwise configured. + #[allow(clippy::module_inception)] #[cfg_attr(target_os = "linux", path = "linux.rs")] #[cfg_attr(target_os = "windows", path = "windows.rs")] diff --git a/apps/desktop/desktop_native/core/src/process_isolation/windows.rs b/apps/desktop/desktop_native/core/src/process_isolation/windows.rs index 7c7864fbbd7..dc1092f9131 100644 --- a/apps/desktop/desktop_native/core/src/process_isolation/windows.rs +++ b/apps/desktop/desktop_native/core/src/process_isolation/windows.rs @@ -8,6 +8,17 @@ pub fn is_core_dumping_disabled() -> Result { bail!("Not implemented on Windows") } -pub fn disable_memory_access() -> Result<()> { - bail!("Not implemented on Windows") +pub fn isolate_process() -> Result<()> { + let pid: u32 = std::process::id(); + println!( + "[Process Isolation] Isolating main process via DACL {}", + pid + ); + + secmem_proc::harden_process().map_err(|e| { + anyhow::anyhow!( + "failed to isolate process, memory may be accessible by other processes {}", + e + ) + }) } diff --git a/apps/desktop/desktop_native/napi/Cargo.toml b/apps/desktop/desktop_native/napi/Cargo.toml index 8f2a5cb78a9..9e8404ea8dc 100644 --- a/apps/desktop/desktop_native/napi/Cargo.toml +++ b/apps/desktop/desktop_native/napi/Cargo.toml @@ -17,6 +17,7 @@ manual_test = [] anyhow = { workspace = true } autotype = { path = "../autotype" } base64 = { workspace = true } +bitwarden_chromium_importer = { path = "../bitwarden_chromium_importer" } desktop_core = { path = "../core" } hex = { workspace = true } log = { workspace = true } diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 5ea75bd6120..281bfd5d69f 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -82,7 +82,7 @@ export declare namespace sshagent { export declare namespace processisolations { export function disableCoredumps(): Promise export function isCoreDumpingDisabled(): Promise - export function disableMemoryAccess(): Promise + export function isolateProcess(): Promise } export declare namespace powermonitors { export function onLock(callback: (err: Error | null, ) => any): Promise @@ -208,6 +208,30 @@ export declare namespace logging { } export function initNapiLog(jsLogFn: (err: Error | null, arg0: LogLevel, arg1: string) => any): void } +export declare namespace chromium_importer { + export interface ProfileInfo { + id: string + name: string + } + export interface Login { + url: string + username: string + password: string + note: string + } + export interface LoginImportFailure { + url: string + username: string + error: string + } + export interface LoginImportResult { + login?: Login + failure?: LoginImportFailure + } + export function getInstalledBrowsers(): Promise> + export function getAvailableProfiles(browser: string): Promise> + export function importLogins(browser: string, profileId: string): Promise> +} export declare namespace autotype { export function getForegroundWindowTitle(): string export function typeInput(input: Array): void diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index 8a70a99b9cd..455f898c87f 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -336,8 +336,8 @@ pub mod processisolations { #[allow(clippy::unused_async)] // FIXME: Remove unused async! #[napi] - pub async fn disable_memory_access() -> napi::Result<()> { - desktop_core::process_isolation::disable_memory_access() + pub async fn isolate_process() -> napi::Result<()> { + desktop_core::process_isolation::isolate_process() .map_err(|e| napi::Error::from_reason(e.to_string())) } } @@ -878,6 +878,96 @@ pub mod logging { } } +#[napi] +pub mod chromium_importer { + use bitwarden_chromium_importer::chromium::LoginImportResult as _LoginImportResult; + use bitwarden_chromium_importer::chromium::ProfileInfo as _ProfileInfo; + + #[napi(object)] + pub struct ProfileInfo { + pub id: String, + pub name: String, + } + + #[napi(object)] + pub struct Login { + pub url: String, + pub username: String, + pub password: String, + pub note: String, + } + + #[napi(object)] + pub struct LoginImportFailure { + pub url: String, + pub username: String, + pub error: String, + } + + #[napi(object)] + pub struct LoginImportResult { + pub login: Option, + pub failure: Option, + } + + impl From<_LoginImportResult> for LoginImportResult { + fn from(l: _LoginImportResult) -> Self { + match l { + _LoginImportResult::Success(l) => LoginImportResult { + login: Some(Login { + url: l.url, + username: l.username, + password: l.password, + note: l.note, + }), + failure: None, + }, + _LoginImportResult::Failure(l) => LoginImportResult { + login: None, + failure: Some(LoginImportFailure { + url: l.url, + username: l.username, + error: l.error, + }), + }, + } + } + } + + impl From<_ProfileInfo> for ProfileInfo { + fn from(p: _ProfileInfo) -> Self { + ProfileInfo { + id: p.folder, + name: p.name, + } + } + } + + #[napi] + pub fn get_installed_browsers() -> napi::Result> { + bitwarden_chromium_importer::chromium::get_installed_browsers() + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub fn get_available_profiles(browser: String) -> napi::Result> { + bitwarden_chromium_importer::chromium::get_available_profiles(&browser) + .map(|profiles| profiles.into_iter().map(ProfileInfo::from).collect()) + .map_err(|e| napi::Error::from_reason(e.to_string())) + } + + #[napi] + pub async fn import_logins( + browser: String, + profile_id: String, + ) -> napi::Result> { + bitwarden_chromium_importer::chromium::import_logins(&browser, &profile_id) + .await + .map(|logins| logins.into_iter().map(LoginImportResult::from).collect()) + .map_err(|e| napi::Error::from_reason(e.to_string())) + } +} + #[napi] pub mod autotype { #[napi] diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index 091864e59ae..4af12903a24 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -330,6 +330,33 @@ "enableBrowserIntegrationFingerprintDesc" | i18n }} +
+
+ +
+ + {{ "important" | i18n }} + {{ "enableAutotypeDescriptionTransitionKey" | i18n }} + {{ "editShortcut" | i18n }} +
-
-
- -
- {{ "important" | i18n }} {{ "enableAutotypeDescription" | i18n }} -
- {{ "confirmNewMasterPass" | i18n }} + {{ + flow === InputPasswordFlow.SetInitialPasswordAccountRegistration || + flow === InputPasswordFlow.SetInitialPasswordAuthedUser + ? ("confirmMasterPassword" | i18n) + : ("confirmNewMasterPass" | i18n) + }} { this.redirectUri = env.getWebVaultUrl() + "/sso-connector.html"; @@ -444,6 +446,15 @@ export class SsoComponent implements OnInit { authResult.userId, ); + if ( + (await firstValueFrom( + this.keyConnectorService.requiresDomainConfirmation$(authResult.userId), + )) != null + ) { + await this.router.navigate(["confirm-key-connector-domain"]); + return; + } + // must come after 2fa check since user decryption options aren't available if 2fa is required const userDecryptionOpts = await firstValueFrom( this.userDecryptionOptionsService.userDecryptionOptions$, diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts index 62271feee59..9418030d7a1 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.spec.ts @@ -24,6 +24,7 @@ import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication- import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, @@ -79,6 +80,7 @@ describe("TwoFactorAuthComponent", () => { let mockTwoFactorAuthCompCacheService: MockProxy; let mockAuthService: MockProxy; let mockConfigService: MockProxy; + let mockKeyConnnectorService: MockProxy; let mockUserDecryptionOpts: { noMasterPassword: UserDecryptionOptions; @@ -115,6 +117,8 @@ describe("TwoFactorAuthComponent", () => { mockTwoFactorAuthCompService = mock(); mockAuthService = mock(); mockConfigService = mock(); + mockKeyConnnectorService = mock(); + mockKeyConnnectorService.requiresDomainConfirmation$.mockReturnValue(of(null)); mockEnvService = mock(); mockLoginSuccessHandlerService = mock(); @@ -215,6 +219,7 @@ describe("TwoFactorAuthComponent", () => { { provide: AuthService, useValue: mockAuthService }, { provide: ConfigService, useValue: mockConfigService }, { provide: MasterPasswordServiceAbstraction, useValue: mockMasterPasswordService }, + { provide: KeyConnectorService, useValue: mockKeyConnnectorService }, ], }); @@ -404,6 +409,24 @@ describe("TwoFactorAuthComponent", () => { }); }); }); + + it("navigates to /confirm-key-connector-domain when Key Connector is enabled and user has no master password", async () => { + selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPasswordWithKeyConnector); + mockKeyConnnectorService.requiresDomainConfirmation$.mockReturnValue( + of({ + keyConnectorUrl: + mockUserDecryptionOpts.noMasterPasswordWithKeyConnector.keyConnectorOption! + .keyConnectorUrl, + }), + ); + const authResult = new AuthResult(); + authResult.userId = userId; + mockLoginStrategyService.logInTwoFactor.mockResolvedValue(authResult); + + await component.submit(token, remember); + + expect(mockRouter.navigate).toHaveBeenCalledWith(["confirm-key-connector-domain"]); + }); }); }); }); diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts index 035598b873b..4c0784928d4 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts @@ -38,6 +38,7 @@ import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-p import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result"; import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request"; +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -166,6 +167,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { private loginSuccessHandlerService: LoginSuccessHandlerService, private twoFactorAuthComponentCacheService: TwoFactorAuthComponentCacheService, private authService: AuthService, + private keyConnectorService: KeyConnectorService, ) {} async ngOnInit() { @@ -455,6 +457,15 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { ); } + if ( + (await firstValueFrom( + this.keyConnectorService.requiresDomainConfirmation$(authResult.userId), + )) != null + ) { + await this.router.navigate(["confirm-key-connector-domain"]); + return; + } + const userDecryptionOpts = await firstValueFrom( this.userDecryptionOptionsService.userDecryptionOptions$, ); diff --git a/libs/auth/src/common/login-strategies/login.strategy.ts b/libs/auth/src/common/login-strategies/login.strategy.ts index 4c7a38254d7..88c247ba711 100644 --- a/libs/auth/src/common/login-strategies/login.strategy.ts +++ b/libs/auth/src/common/login-strategies/login.strategy.ts @@ -33,13 +33,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; import { UserId } from "@bitwarden/common/types/guid"; -import { - KeyService, - Argon2KdfConfig, - PBKDF2KdfConfig, - KdfConfigService, - KdfType, -} from "@bitwarden/key-management"; +import { KeyService, KdfConfigService } from "@bitwarden/key-management"; import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction"; import { @@ -220,16 +214,7 @@ export abstract class LoginStrategy { tokenResponse.refreshToken, // Note: CLI login via API key sends undefined for refresh token. ); - await this.KdfConfigService.setKdfConfig( - userId as UserId, - tokenResponse.kdf === KdfType.PBKDF2_SHA256 - ? new PBKDF2KdfConfig(tokenResponse.kdfIterations) - : new Argon2KdfConfig( - tokenResponse.kdfIterations, - tokenResponse.kdfMemory, - tokenResponse.kdfParallelism, - ), - ); + await this.KdfConfigService.setKdfConfig(userId as UserId, tokenResponse.kdfConfig); await this.billingAccountProfileStateService.setHasPremium( accountInformation.premium ?? false, diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts index f057dc47c63..bce05b35e62 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.spec.ts @@ -33,8 +33,8 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { UserId } from "@bitwarden/common/types/guid"; -import { DeviceKey, UserKey, MasterKey } from "@bitwarden/common/types/key"; -import { KdfConfigService, KeyService } from "@bitwarden/key-management"; +import { DeviceKey, MasterKey, UserKey } from "@bitwarden/common/types/key"; +import { Argon2KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management"; import { AuthRequestServiceAbstraction, @@ -518,15 +518,19 @@ describe("SsoLoginStrategy", () => { }); it("converts new SSO user with no master password to Key Connector on first login", async () => { - tokenResponse.key = null; + tokenResponse.key = undefined; + tokenResponse.kdfConfig = new Argon2KdfConfig(10, 64, 4); apiService.postIdentityToken.mockResolvedValue(tokenResponse); await ssoLoginStrategy.logIn(credentials); - expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith( - tokenResponse, - ssoOrgId, + expect(keyConnectorService.setNewSsoUserKeyConnectorConversionData).toHaveBeenCalledWith( + { + kdfConfig: new Argon2KdfConfig(10, 64, 4), + keyConnectorUrl: keyConnectorUrl, + organizationId: ssoOrgId, + }, userId, ); }); @@ -574,15 +578,19 @@ describe("SsoLoginStrategy", () => { }); it("converts new SSO user with no master password to Key Connector on first login", async () => { - tokenResponse.key = null; + tokenResponse.key = undefined; + tokenResponse.kdfConfig = new Argon2KdfConfig(10, 64, 4); apiService.postIdentityToken.mockResolvedValue(tokenResponse); await ssoLoginStrategy.logIn(credentials); - expect(keyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith( - tokenResponse, - ssoOrgId, + expect(keyConnectorService.setNewSsoUserKeyConnectorConversionData).toHaveBeenCalledWith( + { + kdfConfig: new Argon2KdfConfig(10, 64, 4), + keyConnectorUrl: keyConnectorUrl, + organizationId: ssoOrgId, + }, userId, ); }); diff --git a/libs/auth/src/common/login-strategies/sso-login.strategy.ts b/libs/auth/src/common/login-strategies/sso-login.strategy.ts index 6f1231b3559..ec7914b087e 100644 --- a/libs/auth/src/common/login-strategies/sso-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/sso-login.strategy.ts @@ -125,9 +125,13 @@ export class SsoLoginStrategy extends LoginStrategy { // The presence of a masterKeyEncryptedUserKey indicates that the user has already been provisioned in Key Connector. const newSsoUser = tokenResponse.key == null; if (newSsoUser) { - await this.keyConnectorService.convertNewSsoUserToKeyConnector( - tokenResponse, - this.cache.value.orgId, + // Store Key Connector domain confirmation data in state instead of AuthResult + await this.keyConnectorService.setNewSsoUserKeyConnectorConversionData( + { + kdfConfig: tokenResponse.kdfConfig, + keyConnectorUrl: this.getKeyConnectorUrl(tokenResponse), + organizationId: this.cache.value.orgId, + }, userId, ); } else { @@ -327,10 +331,12 @@ export class SsoLoginStrategy extends LoginStrategy { private async trySetUserKeyWithMasterKey(userId: UserId): Promise { const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); - // There is a scenario in which the master key is not set here. That will occur if the user - // has a master password and is using Key Connector. In that case, we cannot set the master key + // There are two scenarios in which the master key is not set here: + // 1. If the user has a master password and is using Key Connector. In that case, we cannot set the master key // because the user hasn't entered their master password yet. - // Instead, we'll return here and let the migration to Key Connector handle setting the master key. + // 2. For new users with Key Connector, we will not have a master key yet, since Key Connector domain + // has to be confirmed first. + // In both cases, we'll return here and let the migration to Key Connector handle setting the master key. if (!masterKey) { return; } diff --git a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts index a6677350ee9..dbce7628335 100644 --- a/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts +++ b/libs/auth/src/common/login-strategies/webauthn-login.strategy.ts @@ -57,9 +57,7 @@ export class WebAuthnLoginStrategy extends LoginStrategy { throw new Error("2FA not supported yet for WebAuthn Login."); } - protected override async setMasterKey(response: IdentityTokenResponse, userId: UserId) { - return Promise.resolve(); - } + protected override async setMasterKey(response: IdentityTokenResponse, userId: UserId) {} protected override async setUserKey(idTokenResponse: IdentityTokenResponse, userId: UserId) { const masterKeyEncryptedUserKey = idTokenResponse.key; diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 726b04534ad..ab217c56fc4 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -127,11 +127,34 @@ import { OptionalCipherResponse } from "../vault/models/response/optional-cipher * of this decision please read https://contributing.bitwarden.com/architecture/adr/refactor-api-service. */ export abstract class ApiService { + /** @deprecated Use the overload accepting the user you want the request authenticated for. */ abstract send( method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: string, body: any, - authed: boolean, + authed: true, + hasResponse: boolean, + apiUrl?: string | null, + alterHeaders?: (header: Headers) => void, + ): Promise; + + /** Sends an unauthenticated API request. */ + abstract send( + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + path: string, + body: any, + authed: false, + hasResponse: boolean, + apiUrl?: string | null, + alterHeaders?: (header: Headers) => void, + ): Promise; + + /** Sends an API request authenticated with the given users ID. */ + abstract send( + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + path: string, + body: any, + userId: UserId, hasResponse: boolean, apiUrl?: string | null, alterHeaders?: (headers: Headers) => void, @@ -499,7 +522,7 @@ export abstract class ApiService { abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise; abstract postSetupPayment(): Promise; - abstract getActiveBearerToken(): Promise; + abstract getActiveBearerToken(userId: UserId): Promise; abstract fetch(request: Request): Promise; abstract nativeFetch(request: Request): Promise; diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index 770cfd0011d..58d6d9efef9 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -51,6 +51,9 @@ export function canAccessOrgAdmin(org: Organization): boolean { ); } +/** + * @deprecated Please use the general `getById` custom rxjs operator instead. + */ export function getOrganizationById(id: string) { return map((orgs) => orgs.find((o) => o.id === id)); } diff --git a/libs/common/src/auth/abstractions/token.service.ts b/libs/common/src/auth/abstractions/token.service.ts index 2139f32fca2..673bc7bdf0a 100644 --- a/libs/common/src/auth/abstractions/token.service.ts +++ b/libs/common/src/auth/abstractions/token.service.ts @@ -72,14 +72,14 @@ export abstract class TokenService { * @param userId - The optional user id to get the access token for; if not provided, the active user is used. * @returns A promise that resolves with the access token or null. */ - abstract getAccessToken(userId?: UserId): Promise; + abstract getAccessToken(userId: UserId): Promise; /** * Gets the refresh token. * @param userId - The optional user id to get the refresh token for; if not provided, the active user is used. * @returns A promise that resolves with the refresh token or null. */ - abstract getRefreshToken(userId?: UserId): Promise; + abstract getRefreshToken(userId: UserId): Promise; /** * Sets the API Key Client ID for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout. @@ -96,10 +96,10 @@ export abstract class TokenService { ): Promise; /** - * Gets the API Key Client ID for the active user. + * Gets the API Key Client ID for the given user. * @returns A promise that resolves with the API Key Client ID or undefined */ - abstract getClientId(userId?: UserId): Promise; + abstract getClientId(userId: UserId): Promise; /** * Sets the API Key Client Secret for the active user id in memory or disk based on the given vaultTimeoutAction and vaultTimeout. @@ -116,10 +116,10 @@ export abstract class TokenService { ): Promise; /** - * Gets the API Key Client Secret for the active user. + * Gets the API Key Client Secret for the given user. * @returns A promise that resolves with the API Key Client Secret or undefined */ - abstract getClientSecret(userId?: UserId): Promise; + abstract getClientSecret(userId: UserId): Promise; /** * Sets the two factor token for the given email in global state. @@ -157,7 +157,7 @@ export abstract class TokenService { * Gets the expiration date for the access token. Returns if token can't be decoded or has no expiration * @returns A promise that resolves with the expiration date for the access token. */ - abstract getTokenExpirationDate(): Promise; + abstract getTokenExpirationDate(userId: UserId): Promise; /** * Calculates the adjusted time in seconds until the access token expires, considering an optional offset. @@ -168,14 +168,14 @@ export abstract class TokenService { * based on the actual expiration. * @returns {Promise} Promise resolving to the adjusted seconds remaining. */ - abstract tokenSecondsRemaining(offsetSeconds?: number): Promise; + abstract tokenSecondsRemaining(userId: UserId, offsetSeconds?: number): Promise; /** * Checks if the access token needs to be refreshed. * @param {number} [minutes=5] - Optional number of minutes before the access token expires to consider refreshing it. * @returns A promise that resolves with a boolean indicating if the access token needs to be refreshed. */ - abstract tokenNeedsRefresh(minutes?: number): Promise; + abstract tokenNeedsRefresh(userId: UserId, minutes?: number): Promise; /** * Gets the user id for the active user from the access token. diff --git a/libs/common/src/auth/models/response/identity-token.response.ts b/libs/common/src/auth/models/response/identity-token.response.ts index 53242a25b21..2a63986a8ab 100644 --- a/libs/common/src/auth/models/response/identity-token.response.ts +++ b/libs/common/src/auth/models/response/identity-token.response.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { KdfType } from "@bitwarden/key-management"; +import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management"; import { EncString } from "../../../key-management/crypto/models/enc-string"; import { BaseResponse } from "../../../models/response/base.response"; @@ -20,10 +20,7 @@ export class IdentityTokenResponse extends BaseResponse { privateKey: string; // userKeyEncryptedPrivateKey key?: EncString; // masterKeyEncryptedUserKey twoFactorToken: string; - kdf: KdfType; - kdfIterations: number; - kdfMemory?: number; - kdfParallelism?: number; + kdfConfig: KdfConfig; forcePasswordReset: boolean; masterPasswordPolicy: MasterPasswordPolicyResponse; apiUseKeyConnector: boolean; @@ -45,10 +42,14 @@ export class IdentityTokenResponse extends BaseResponse { this.key = new EncString(key); } this.twoFactorToken = this.getResponseProperty("TwoFactorToken"); - this.kdf = this.getResponseProperty("Kdf"); - this.kdfIterations = this.getResponseProperty("KdfIterations"); - this.kdfMemory = this.getResponseProperty("KdfMemory"); - this.kdfParallelism = this.getResponseProperty("KdfParallelism"); + const kdf = this.getResponseProperty("Kdf"); + const kdfIterations = this.getResponseProperty("KdfIterations"); + const kdfMemory = this.getResponseProperty("KdfMemory"); + const kdfParallelism = this.getResponseProperty("KdfParallelism"); + this.kdfConfig = + kdf == KdfType.PBKDF2_SHA256 + ? new PBKDF2KdfConfig(kdfIterations) + : new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism); this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset"); this.apiUseKeyConnector = this.getResponseProperty("ApiUseKeyConnector"); this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl"); diff --git a/libs/common/src/auth/services/auth-request-answering/unsupported-auth-request-answering.service.ts b/libs/common/src/auth/services/auth-request-answering/noop-auth-request-answering.service.ts similarity index 59% rename from libs/common/src/auth/services/auth-request-answering/unsupported-auth-request-answering.service.ts rename to libs/common/src/auth/services/auth-request-answering/noop-auth-request-answering.service.ts index c4f503bd39c..9e229ef1129 100644 --- a/libs/common/src/auth/services/auth-request-answering/unsupported-auth-request-answering.service.ts +++ b/libs/common/src/auth/services/auth-request-answering/noop-auth-request-answering.service.ts @@ -3,15 +3,9 @@ import { UserId } from "@bitwarden/user-core"; import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction"; -export class UnsupportedAuthRequestAnsweringService - implements AuthRequestAnsweringServiceAbstraction -{ +export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction { constructor() {} - async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise { - throw new Error("Received pending auth request not supported."); - } + async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise {} - async receivedPendingAuthRequest(userId: UserId, notificationId: string): Promise { - throw new Error("Received pending auth request not supported."); - } + async receivedPendingAuthRequest(userId: UserId, notificationId: string): Promise {} } diff --git a/libs/common/src/auth/services/token.service.spec.ts b/libs/common/src/auth/services/token.service.spec.ts index 7274954c950..f4e4ec5e204 100644 --- a/libs/common/src/auth/services/token.service.spec.ts +++ b/libs/common/src/auth/services/token.service.spec.ts @@ -409,28 +409,8 @@ describe("TokenService", () => { }); describe("getAccessToken", () => { - it("returns null when no user id is provided and there is no active user in global state", async () => { - // Act - const result = await tokenService.getAccessToken(); - // Assert - expect(result).toBeNull(); - }); - - it("returns null when no access token is found in memory, disk, or secure storage", async () => { - // Arrange - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getAccessToken(); - // Assert - expect(result).toBeNull(); - }); - describe("Memory storage tests", () => { - test.each([ - ["gets the access token from memory when a user id is provided ", userIdFromAccessToken], - ["gets the access token from memory when no user id is provided", undefined], - ])("%s", async (_, userId) => { + it("gets the access token from memory when a user id is provided ", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -442,12 +422,10 @@ describe("TokenService", () => { .nextState(undefined); // Need to have global active id set to the user id - if (!userId) { - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - } + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act - const result = await tokenService.getAccessToken(userId); + const result = await tokenService.getAccessToken(userIdFromAccessToken); // Assert expect(result).toEqual(accessTokenJwt); @@ -455,10 +433,7 @@ describe("TokenService", () => { }); describe("Disk storage tests (secure storage not supported on platform)", () => { - test.each([ - ["gets the access token from disk when the user id is specified", userIdFromAccessToken], - ["gets the access token from disk when no user id is specified", undefined], - ])("%s", async (_, userId) => { + it("gets the access token from disk when the user id is specified", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -469,12 +444,10 @@ describe("TokenService", () => { .nextState(accessTokenJwt); // Need to have global active id set to the user id - if (!userId) { - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - } + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act - const result = await tokenService.getAccessToken(userId); + const result = await tokenService.getAccessToken(userIdFromAccessToken); // Assert expect(result).toEqual(accessTokenJwt); }); @@ -486,16 +459,7 @@ describe("TokenService", () => { tokenService = createTokenService(supportsSecureStorage); }); - test.each([ - [ - "gets the encrypted access token from disk, decrypts it, and returns it when a user id is provided", - userIdFromAccessToken, - ], - [ - "gets the encrypted access token from disk, decrypts it, and returns it when no user id is provided", - undefined, - ], - ])("%s", async (_, userId) => { + it("gets the encrypted access token from disk, decrypts it, and returns it when a user id is provided", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -509,27 +473,17 @@ describe("TokenService", () => { encryptService.decryptString.mockResolvedValue("decryptedAccessToken"); // Need to have global active id set to the user id - if (!userId) { - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - } + + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act - const result = await tokenService.getAccessToken(userId); + const result = await tokenService.getAccessToken(userIdFromAccessToken); // Assert expect(result).toEqual("decryptedAccessToken"); }); - test.each([ - [ - "falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided", - userIdFromAccessToken, - ], - [ - "falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and no user id is provided", - undefined, - ], - ])("%s", async (_, userId) => { + it("falls back and gets the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided", async () => { // Arrange singleUserStateProvider .getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY) @@ -540,14 +494,12 @@ describe("TokenService", () => { .nextState(accessTokenJwt); // Need to have global active id set to the user id - if (!userId) { - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - } + globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // No access token key set // Act - const result = await tokenService.getAccessToken(userId); + const result = await tokenService.getAccessToken(userIdFromAccessToken); // Assert expect(result).toEqual(accessTokenJwt); @@ -738,7 +690,7 @@ describe("TokenService", () => { // Act // note: don't await here because we want to test the error - const result = tokenService.getTokenExpirationDate(); + const result = tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert await expect(result).rejects.toThrow("Failed to decode access token: Mock error"); }); @@ -748,7 +700,7 @@ describe("TokenService", () => { tokenService.decodeAccessToken = jest.fn().mockResolvedValue(null); // Act - const result = await tokenService.getTokenExpirationDate(); + const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert expect(result).toBeNull(); @@ -763,7 +715,7 @@ describe("TokenService", () => { .mockResolvedValue(accessTokenDecodedWithoutExp); // Act - const result = await tokenService.getTokenExpirationDate(); + const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert expect(result).toBeNull(); @@ -777,7 +729,7 @@ describe("TokenService", () => { .mockResolvedValue(accessTokenDecodedWithNonNumericExp); // Act - const result = await tokenService.getTokenExpirationDate(); + const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert expect(result).toBeNull(); @@ -788,7 +740,7 @@ describe("TokenService", () => { tokenService.decodeAccessToken = jest.fn().mockResolvedValue(accessTokenDecoded); // Act - const result = await tokenService.getTokenExpirationDate(); + const result = await tokenService.getTokenExpirationDate(userIdFromAccessToken); // Assert expect(result).toEqual(new Date(accessTokenDecoded.exp * 1000)); @@ -801,7 +753,7 @@ describe("TokenService", () => { tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(null); // Act - const result = await tokenService.tokenSecondsRemaining(); + const result = await tokenService.tokenSecondsRemaining(userIdFromAccessToken); // Assert expect(result).toEqual(0); @@ -823,7 +775,7 @@ describe("TokenService", () => { tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate); // Act - const result = await tokenService.tokenSecondsRemaining(); + const result = await tokenService.tokenSecondsRemaining(userIdFromAccessToken); // Assert expect(result).toEqual(expectedSecondsRemaining); @@ -849,7 +801,10 @@ describe("TokenService", () => { tokenService.getTokenExpirationDate = jest.fn().mockResolvedValue(expirationDate); // Act - const result = await tokenService.tokenSecondsRemaining(offsetSeconds); + const result = await tokenService.tokenSecondsRemaining( + userIdFromAccessToken, + offsetSeconds, + ); // Assert expect(result).toEqual(expectedSecondsRemaining); @@ -866,7 +821,7 @@ describe("TokenService", () => { tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); // Act - const result = await tokenService.tokenNeedsRefresh(); + const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken); // Assert expect(result).toEqual(true); @@ -878,7 +833,7 @@ describe("TokenService", () => { tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); // Act - const result = await tokenService.tokenNeedsRefresh(); + const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken); // Assert expect(result).toEqual(false); @@ -890,7 +845,7 @@ describe("TokenService", () => { tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); // Act - const result = await tokenService.tokenNeedsRefresh(2); + const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken, 2); // Assert expect(result).toEqual(true); @@ -902,7 +857,7 @@ describe("TokenService", () => { tokenService.tokenSecondsRemaining = jest.fn().mockResolvedValue(tokenSecondsRemaining); // Act - const result = await tokenService.tokenNeedsRefresh(5); + const result = await tokenService.tokenNeedsRefresh(userIdFromAccessToken, 5); // Assert expect(result).toEqual(false); @@ -1565,26 +1520,6 @@ describe("TokenService", () => { }); describe("Memory storage tests", () => { - it("gets the refresh token from memory when no user id is specified (uses global active user)", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .nextState(refreshToken); - - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .nextState(undefined); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getRefreshToken(); - - // Assert - expect(result).toEqual(refreshToken); - }); - it("gets the refresh token from memory when a user id is specified", async () => { // Arrange singleUserStateProvider @@ -1603,25 +1538,6 @@ describe("TokenService", () => { }); describe("Disk storage tests (secure storage not supported on platform)", () => { - it("gets the refresh token from disk when no user id is specified", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .nextState(refreshToken); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getRefreshToken(); - // Assert - expect(result).toEqual(refreshToken); - }); - it("gets the refresh token from disk when a user id is specified", async () => { // Arrange singleUserStateProvider @@ -1645,27 +1561,6 @@ describe("TokenService", () => { tokenService = createTokenService(supportsSecureStorage); }); - it("gets the refresh token from secure storage when no user id is specified", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .nextState(undefined); - - secureStorageService.get.mockResolvedValue(refreshToken); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getRefreshToken(); - // Assert - expect(result).toEqual(refreshToken); - }); - it("gets the refresh token from secure storage when a user id is specified", async () => { // Arrange @@ -1705,29 +1600,6 @@ describe("TokenService", () => { expect(secureStorageService.get).not.toHaveBeenCalled(); }); - it("falls back and gets the refresh token from disk when no user id is specified even if the platform supports secure storage", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, REFRESH_TOKEN_DISK) - .nextState(refreshToken); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getRefreshToken(); - - // Assert - expect(result).toEqual(refreshToken); - - // assert that secure storage was not called - expect(secureStorageService.get).not.toHaveBeenCalled(); - }); - it("returns null when the refresh token is not found in memory, on disk, or in secure storage", async () => { // Arrange secureStorageService.get.mockResolvedValue(null); @@ -1944,45 +1816,7 @@ describe("TokenService", () => { }); describe("getClientId", () => { - it("returns undefined when no user id is provided and there is no active user in global state", async () => { - // Act - const result = await tokenService.getClientId(); - // Assert - expect(result).toBeUndefined(); - }); - - it("returns null when no client id is found in memory or disk", async () => { - // Arrange - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientId(); - // Assert - expect(result).toBeNull(); - }); - describe("Memory storage tests", () => { - it("gets the client id from memory when no user id is specified (uses global active user)", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) - .nextState(clientId); - - // set disk to undefined - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) - .nextState(undefined); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientId(); - - // Assert - expect(result).toEqual(clientId); - }); - it("gets the client id from memory when given a user id", async () => { // Arrange singleUserStateProvider @@ -2002,25 +1836,6 @@ describe("TokenService", () => { }); describe("Disk storage tests", () => { - it("gets the client id from disk when no user id is specified", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_ID_DISK) - .nextState(clientId); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientId(); - // Assert - expect(result).toEqual(clientId); - }); - it("gets the client id from disk when a user id is specified", async () => { // Arrange singleUserStateProvider @@ -2215,45 +2030,17 @@ describe("TokenService", () => { }); describe("getClientSecret", () => { - it("returns undefined when no user id is provided and there is no active user in global state", async () => { - // Act - const result = await tokenService.getClientSecret(); - // Assert - expect(result).toBeUndefined(); - }); - it("returns null when no client secret is found in memory or disk", async () => { // Arrange globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); // Act - const result = await tokenService.getClientSecret(); + const result = await tokenService.getClientSecret(userIdFromAccessToken); // Assert expect(result).toBeNull(); }); describe("Memory storage tests", () => { - it("gets the client secret from memory when no user id is specified (uses global active user)", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) - .nextState(clientSecret); - - // set disk to undefined - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) - .nextState(undefined); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientSecret(); - - // Assert - expect(result).toEqual(clientSecret); - }); - it("gets the client secret from memory when a user id is specified", async () => { // Arrange singleUserStateProvider @@ -2273,25 +2060,6 @@ describe("TokenService", () => { }); describe("Disk storage tests", () => { - it("gets the client secret from disk when no user id specified", async () => { - // Arrange - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_MEMORY) - .nextState(undefined); - - singleUserStateProvider - .getFake(userIdFromAccessToken, API_KEY_CLIENT_SECRET_DISK) - .nextState(clientSecret); - - // Need to have global active id set to the user id - globalStateProvider.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID).nextState(userIdFromAccessToken); - - // Act - const result = await tokenService.getClientSecret(); - // Assert - expect(result).toEqual(clientSecret); - }); - it("gets the client secret from disk when a user id is specified", async () => { // Arrange singleUserStateProvider diff --git a/libs/common/src/auth/services/token.service.ts b/libs/common/src/auth/services/token.service.ts index 21ccd672056..0721927bd13 100644 --- a/libs/common/src/auth/services/token.service.ts +++ b/libs/common/src/auth/services/token.service.ts @@ -452,9 +452,7 @@ export class TokenService implements TokenServiceAbstraction { await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null); } - async getAccessToken(userId?: UserId): Promise { - userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); - + async getAccessToken(userId: UserId): Promise { if (!userId) { return null; } @@ -631,9 +629,7 @@ export class TokenService implements TokenServiceAbstraction { } } - async getRefreshToken(userId?: UserId): Promise { - userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); - + async getRefreshToken(userId: UserId): Promise { if (!userId) { return null; } @@ -746,9 +742,7 @@ export class TokenService implements TokenServiceAbstraction { } } - async getClientId(userId?: UserId): Promise { - userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); - + async getClientId(userId: UserId): Promise { if (!userId) { return undefined; } @@ -822,9 +816,7 @@ export class TokenService implements TokenServiceAbstraction { } } - async getClientSecret(userId?: UserId): Promise { - userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$); - + async getClientSecret(userId: UserId): Promise { if (!userId) { return undefined; } @@ -915,7 +907,9 @@ export class TokenService implements TokenServiceAbstraction { if (Utils.isGuid(tokenOrUserId)) { token = await this.getAccessToken(tokenOrUserId as UserId); } else { - token ??= await this.getAccessToken(); + token ??= await this.getAccessToken( + await firstValueFrom(this.activeUserIdGlobalState.state$), + ); } if (token == null) { @@ -928,10 +922,10 @@ export class TokenService implements TokenServiceAbstraction { // TODO: PM-6678- tech debt - consider consolidating the return types of all these access // token data retrieval methods to return null if something goes wrong instead of throwing an error. - async getTokenExpirationDate(): Promise { + async getTokenExpirationDate(userId: UserId): Promise { let decoded: DecodedAccessToken; try { - decoded = await this.decodeAccessToken(); + decoded = await this.decodeAccessToken(userId); } catch (error) { throw new Error("Failed to decode access token: " + error.message); } @@ -947,8 +941,8 @@ export class TokenService implements TokenServiceAbstraction { return expirationDate; } - async tokenSecondsRemaining(offsetSeconds = 0): Promise { - const date = await this.getTokenExpirationDate(); + async tokenSecondsRemaining(userId: UserId, offsetSeconds = 0): Promise { + const date = await this.getTokenExpirationDate(userId); if (date == null) { return 0; } @@ -957,8 +951,8 @@ export class TokenService implements TokenServiceAbstraction { return Math.round(msRemaining / 1000); } - async tokenNeedsRefresh(minutes = 5): Promise { - const sRemaining = await this.tokenSecondsRemaining(); + async tokenNeedsRefresh(userId: UserId, minutes = 5): Promise { + const sRemaining = await this.tokenSecondsRemaining(userId); return sRemaining < 60 * minutes; } diff --git a/libs/common/src/billing/abstractions/organization-billing.service.ts b/libs/common/src/billing/abstractions/organization-billing.service.ts index 113b55465a7..9089c165a33 100644 --- a/libs/common/src/billing/abstractions/organization-billing.service.ts +++ b/libs/common/src/billing/abstractions/organization-billing.service.ts @@ -1,7 +1,3 @@ -import { Observable } from "rxjs"; - -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; - import { OrganizationResponse } from "../../admin-console/models/response/organization.response"; import { InitiationPath } from "../../models/request/reference-event.request"; import { PaymentMethodType, PlanType } from "../enums"; @@ -63,10 +59,4 @@ export abstract class OrganizationBillingServiceAbstraction { organizationId: string, subscription: SubscriptionInformation, ): Promise; - - /** - * Determines if breadcrumbing policies is enabled for the organizations meeting certain criteria. - * @param organization - */ - abstract isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable; } diff --git a/libs/common/src/billing/services/organization-billing.service.spec.ts b/libs/common/src/billing/services/organization-billing.service.spec.ts index 43457f810d1..1e666e75bb6 100644 --- a/libs/common/src/billing/services/organization-billing.service.spec.ts +++ b/libs/common/src/billing/services/organization-billing.service.spec.ts @@ -1,22 +1,26 @@ import { mock } from "jest-mock-extended"; -import { firstValueFrom, of } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { OrganizationApiServiceAbstraction as OrganizationApiService } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; -import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { + BillingApiServiceAbstraction, + SubscriptionInformation, +} from "@bitwarden/common/billing/abstractions"; +import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SyncService } from "@bitwarden/common/platform/sync"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; -describe("BillingAccountProfileStateService", () => { +import { OrganizationResponse } from "../../admin-console/models/response/organization.response"; +import { EncString } from "../../key-management/crypto/models/enc-string"; +import { OrgKey } from "../../types/key"; +import { PaymentMethodResponse } from "../models/response/payment-method.response"; + +describe("OrganizationBillingService", () => { let apiService: jest.Mocked; let billingApiService: jest.Mocked; let keyService: jest.Mocked; @@ -24,7 +28,6 @@ describe("BillingAccountProfileStateService", () => { let i18nService: jest.Mocked; let organizationApiService: jest.Mocked; let syncService: jest.Mocked; - let configService: jest.Mocked; let sut: OrganizationBillingService; @@ -36,7 +39,6 @@ describe("BillingAccountProfileStateService", () => { i18nService = mock(); organizationApiService = mock(); syncService = mock(); - configService = mock(); sut = new OrganizationBillingService( apiService, @@ -46,7 +48,6 @@ describe("BillingAccountProfileStateService", () => { i18nService, organizationApiService, syncService, - configService, ); }); @@ -54,98 +55,246 @@ describe("BillingAccountProfileStateService", () => { return jest.resetAllMocks(); }); - describe("isBreadcrumbingPoliciesEnabled", () => { - it("returns false when feature flag is disabled", async () => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - const org = { - isProviderUser: false, - canEditSubscription: true, - productTierType: ProductTierType.Teams, - } as Organization; + describe("getPaymentSource()", () => { + it("given a valid organization id, then it returns a payment source", async () => { + //Arrange + const orgId = "organization-test"; + const paymentMethodResponse = { + paymentSource: { type: PaymentMethodType.Card }, + } as PaymentMethodResponse; + billingApiService.getOrganizationPaymentMethod.mockResolvedValue(paymentMethodResponse); - const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org)); - expect(actual).toBe(false); - expect(configService.getFeatureFlag$).toHaveBeenCalledWith( - FeatureFlag.PM12276_BreadcrumbEventLogs, + //Act + const returnedPaymentSource = await sut.getPaymentSource(orgId); + + //Assert + expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1); + expect(returnedPaymentSource).toEqual(paymentMethodResponse.paymentSource); + }); + + it("given an invalid organizationId, it should return undefined", async () => { + //Arrange + const orgId = "invalid-id"; + billingApiService.getOrganizationPaymentMethod.mockResolvedValue(null); + + //Act + const returnedPaymentSource = await sut.getPaymentSource(orgId); + + //Assert + expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1); + expect(returnedPaymentSource).toBeUndefined(); + }); + + it("given an API error occurs, then it throws the error", async () => { + // Arrange + const orgId = "error-org"; + billingApiService.getOrganizationPaymentMethod.mockRejectedValue(new Error("API Error")); + + // Act & Assert + await expect(sut.getPaymentSource(orgId)).rejects.toThrow("API Error"); + expect(billingApiService.getOrganizationPaymentMethod).toHaveBeenCalledTimes(1); + }); + }); + + describe("purchaseSubscription()", () => { + it("given valid subscription information, then it returns successful response", async () => { + //Arrange + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.EnterpriseAnnually2023 }, + payment: { + paymentMethod: ["card-token", PaymentMethodType.Card], + billing: { postalCode: "12345" }, + }, + } as SubscriptionInformation; + + const organizationResponse = { + name: subscriptionInformation.organization.name, + billingEmail: subscriptionInformation.organization.billingEmail, + planType: subscriptionInformation.plan.type, + } as OrganizationResponse; + + organizationApiService.create.mockResolvedValue(organizationResponse); + keyService.makeOrgKey.mockResolvedValue([new EncString("encrypyted-key"), {} as OrgKey]); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypyted-key")]); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted")); + + //Act + const response = await sut.purchaseSubscription(subscriptionInformation); + + //Assert + expect(organizationApiService.create).toHaveBeenCalledTimes(1); + expect(response).toEqual(organizationResponse); + }); + + it("given organization creation fails, then it throws an error", async () => { + // Arrange + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.EnterpriseAnnually2023 }, + payment: { + paymentMethod: ["card-token", PaymentMethodType.Card], + billing: { postalCode: "12345" }, + }, + } as SubscriptionInformation; + + organizationApiService.create.mockRejectedValue(new Error("Failed to create organization")); + keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted")); + + // Act & Assert + await expect(sut.purchaseSubscription(subscriptionInformation)).rejects.toThrow( + "Failed to create organization", ); }); - it("returns false when organization belongs to a provider", async () => { - configService.getFeatureFlag$.mockReturnValue(of(true)); - const org = { - isProviderUser: true, - canEditSubscription: true, - productTierType: ProductTierType.Teams, - } as Organization; + it("given key generation fails, then it throws an error", async () => { + // Arrange + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.EnterpriseAnnually2023 }, + payment: { + paymentMethod: ["card-token", PaymentMethodType.Card], + billing: { postalCode: "12345" }, + }, + } as SubscriptionInformation; - const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org)); - expect(actual).toBe(false); - }); + keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed")); - it("returns false when cannot edit subscription", async () => { - configService.getFeatureFlag$.mockReturnValue(of(true)); - const org = { - isProviderUser: false, - canEditSubscription: false, - productTierType: ProductTierType.Teams, - } as Organization; - - const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org)); - expect(actual).toBe(false); - }); - - it.each([ - ["Teams", ProductTierType.Teams], - ["TeamsStarter", ProductTierType.TeamsStarter], - ])("returns true when all conditions are met with %s tier", async (_, productTierType) => { - configService.getFeatureFlag$.mockReturnValue(of(true)); - const org = { - isProviderUser: false, - canEditSubscription: true, - productTierType: productTierType, - } as Organization; - - const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org)); - expect(actual).toBe(true); - expect(configService.getFeatureFlag$).toHaveBeenCalledWith( - FeatureFlag.PM12276_BreadcrumbEventLogs, + // Act & Assert + await expect(sut.purchaseSubscription(subscriptionInformation)).rejects.toThrow( + "Key generation failed", ); }); - it("returns false when product tier is not supported", async () => { - configService.getFeatureFlag$.mockReturnValue(of(true)); - const org = { - isProviderUser: false, - canEditSubscription: true, - productTierType: ProductTierType.Enterprise, - } as Organization; + it("given an invalid plan type, then it throws an error", async () => { + // Arrange + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: -1 as unknown as PlanType }, + payment: { + paymentMethod: ["card-token", PaymentMethodType.Card], + billing: { postalCode: "12345" }, + }, + } as SubscriptionInformation; - const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org)); - expect(actual).toBe(false); + // Act & Assert + await expect(sut.purchaseSubscription(subscriptionInformation)).rejects.toThrow(); + }); + }); + + describe("purchaseSubscriptionNoPaymentMethod()", () => { + it("given valid subscription information, then it returns successful response", async () => { + //Arrange + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.EnterpriseAnnually2023 }, + } as SubscriptionInformation; + + const organizationResponse = { + name: subscriptionInformation.organization.name, + plan: { type: subscriptionInformation.plan.type }, + planType: subscriptionInformation.plan.type, + } as OrganizationResponse; + + organizationApiService.createWithoutPayment.mockResolvedValue(organizationResponse); + keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypted")); + + //Act + const response = await sut.purchaseSubscriptionNoPaymentMethod(subscriptionInformation); + + //Assert + expect(organizationApiService.createWithoutPayment).toHaveBeenCalledTimes(1); + expect(response).toEqual(organizationResponse); }); - it("handles all conditions false correctly", async () => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - const org = { - isProviderUser: true, - canEditSubscription: false, - productTierType: ProductTierType.Free, - } as Organization; + it("given organization creation fails without payment method, then it throws an error", async () => { + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.EnterpriseAnnually2023 }, + } as SubscriptionInformation; - const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org)); - expect(actual).toBe(false); + organizationApiService.createWithoutPayment.mockRejectedValue(new Error("Creation failed")); + keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypted")); + + await expect( + sut.purchaseSubscriptionNoPaymentMethod(subscriptionInformation), + ).rejects.toThrow("Creation failed"); }); - it("verifies feature flag is only called once", async () => { - configService.getFeatureFlag$.mockReturnValue(of(false)); - const org = { - isProviderUser: false, - canEditSubscription: true, - productTierType: ProductTierType.Teams, - } as Organization; + it("given key generation fails, then it throws an error", async () => { + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.EnterpriseAnnually2023 }, + } as SubscriptionInformation; - await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org)); - expect(configService.getFeatureFlag$).toHaveBeenCalledTimes(1); + keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed")); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); + + await expect( + sut.purchaseSubscriptionNoPaymentMethod(subscriptionInformation), + ).rejects.toThrow("Key generation failed"); + }); + }); + + describe("startFree()", () => { + it("given valid free plan information, then it creates a free organization", async () => { + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.Free }, + } as SubscriptionInformation; + + const organizationResponse = { + name: subscriptionInformation.organization.name, + billingEmail: subscriptionInformation.organization.billingEmail, + planType: subscriptionInformation.plan.type, + } as OrganizationResponse; + + organizationApiService.create.mockResolvedValue(organizationResponse); + keyService.makeOrgKey.mockResolvedValue([new EncString("encrypyted-key"), {} as OrgKey]); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypyted-key")]); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted")); + + //Act + const response = await sut.startFree(subscriptionInformation); + + //Assert + expect(organizationApiService.create).toHaveBeenCalledTimes(1); + expect(response).toEqual(organizationResponse); + }); + + it("given key generation fails, then it throws an error", async () => { + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.Free }, + } as SubscriptionInformation; + + keyService.makeOrgKey.mockRejectedValue(new Error("Key generation failed")); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); + + await expect(sut.startFree(subscriptionInformation)).rejects.toThrow("Key generation failed"); + }); + + it("given organization creation fails, then it throws an error", async () => { + // Arrange + const subscriptionInformation = { + organization: { name: "test-business", billingEmail: "test@test.com" }, + plan: { type: PlanType.Free }, + } as SubscriptionInformation; + + organizationApiService.create.mockRejectedValue(new Error("Failed to create organization")); + keyService.makeOrgKey.mockResolvedValue([new EncString("encrypted-key"), {} as OrgKey]); + keyService.makeKeyPair.mockResolvedValue(["key", new EncString("encrypted-key")]); + encryptService.encryptString.mockResolvedValue(new EncString("collection-encrypyted")); + // Act & Assert + await expect(sut.startFree(subscriptionInformation)).rejects.toThrow( + "Failed to create organization", + ); }); }); }); diff --git a/libs/common/src/billing/services/organization-billing.service.ts b/libs/common/src/billing/services/organization-billing.service.ts index aaf22815404..e4fe2f9a6bd 100644 --- a/libs/common/src/billing/services/organization-billing.service.ts +++ b/libs/common/src/billing/services/organization-billing.service.ts @@ -1,10 +1,5 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { Observable, of, switchMap } from "rxjs"; - -import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; @@ -27,7 +22,7 @@ import { PlanInformation, SubscriptionInformation, } from "../abstractions"; -import { PlanType, ProductTierType } from "../enums"; +import { PlanType } from "../enums"; import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request"; import { PaymentSourceResponse } from "../models/response/payment-source.response"; @@ -47,12 +42,11 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs private i18nService: I18nService, private organizationApiService: OrganizationApiService, private syncService: SyncService, - private configService: ConfigService, ) {} async getPaymentSource(organizationId: string): Promise { const paymentMethod = await this.billingApiService.getOrganizationPaymentMethod(organizationId); - return paymentMethod.paymentSource; + return paymentMethod?.paymentSource; } async purchaseSubscription(subscription: SubscriptionInformation): Promise { @@ -229,29 +223,4 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs this.setPaymentInformation(request, subscription.payment); await this.billingApiService.restartSubscription(organizationId, request); } - - isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable { - if (organization === null || organization === undefined) { - return of(false); - } - - return this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs).pipe( - switchMap((featureFlagEnabled) => { - if (!featureFlagEnabled) { - return of(false); - } - - if (organization.isProviderUser || !organization.canEditSubscription) { - return of(false); - } - - const supportedProducts = [ProductTierType.Teams, ProductTierType.TeamsStarter]; - const isSupportedProduct = supportedProducts.some( - (product) => product === organization.productTierType, - ); - - return of(isSupportedProduct); - }), - ); - } } diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 262b31e624f..b339798f914 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -12,6 +12,7 @@ import { ServerConfig } from "../platform/abstractions/config/server-config"; export enum FeatureFlag { /* Admin Console Team */ CreateDefaultLocation = "pm-19467-create-default-location", + CollectionVaultRefactor = "pm-25030-resolve-ts-upgrade-errors", /* Auth */ PM14938_BrowserExtensionLoginApproval = "pm-14938-browser-extension-login-approvals", @@ -23,7 +24,6 @@ export enum FeatureFlag { /* Billing */ TrialPaymentOptional = "PM-8163-trial-payment", - PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features", PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", UseOrganizationWarningsService = "use-organization-warnings-service", PM21881_ManagePaymentDetailsOutsideCheckout = "pm-21881-manage-payment-details-outside-checkout", @@ -38,6 +38,7 @@ export enum FeatureFlag { /* Tools */ DesktopSendUIRefresh = "desktop-send-ui-refresh", UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators", + UseChromiumImporter = "pm-23982-chromium-importer", /* DIRT */ EventBasedOrganizationIntegrations = "event-based-organization-integrations", @@ -51,6 +52,7 @@ export enum FeatureFlag { /* Platform */ IpcChannelFramework = "ipc-channel-framework", + InactiveUserServerNotification = "pm-25130-receive-push-notifications-for-inactive-users", PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked", } @@ -70,6 +72,7 @@ const FALSE = false as boolean; export const DefaultFeatureFlagValue = { /* Admin Console Team */ [FeatureFlag.CreateDefaultLocation]: FALSE, + [FeatureFlag.CollectionVaultRefactor]: FALSE, /* Autofill */ [FeatureFlag.NotificationRefresh]: FALSE, @@ -79,6 +82,7 @@ export const DefaultFeatureFlagValue = { /* Tools */ [FeatureFlag.DesktopSendUIRefresh]: FALSE, [FeatureFlag.UseSdkPasswordGenerators]: FALSE, + [FeatureFlag.UseChromiumImporter]: FALSE, /* DIRT */ [FeatureFlag.EventBasedOrganizationIntegrations]: FALSE, @@ -95,7 +99,6 @@ export const DefaultFeatureFlagValue = { /* Billing */ [FeatureFlag.TrialPaymentOptional]: FALSE, - [FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE, [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, [FeatureFlag.UseOrganizationWarningsService]: FALSE, [FeatureFlag.PM21881_ManagePaymentDetailsOutsideCheckout]: FALSE, @@ -109,6 +112,7 @@ export const DefaultFeatureFlagValue = { /* Platform */ [FeatureFlag.IpcChannelFramework]: FALSE, + [FeatureFlag.InactiveUserServerNotification]: FALSE, [FeatureFlag.PushNotificationsWhenLocked]: FALSE, } satisfies Record; diff --git a/libs/common/src/key-management/key-connector/abstractions/key-connector.service.ts b/libs/common/src/key-management/key-connector/abstractions/key-connector.service.ts index ed87160832d..c3fedc3333a 100644 --- a/libs/common/src/key-management/key-connector/abstractions/key-connector.service.ts +++ b/libs/common/src/key-management/key-connector/abstractions/key-connector.service.ts @@ -1,8 +1,10 @@ import { Observable } from "rxjs"; +import { NewSsoUserKeyConnectorConversion } from "@bitwarden/common/key-management/key-connector/models/new-sso-user-key-connector-conversion"; + import { Organization } from "../../../admin-console/models/domain/organization"; -import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response"; import { UserId } from "../../../types/guid"; +import { KeyConnectorDomainConfirmation } from "../models/key-connector-domain-confirmation"; export abstract class KeyConnectorService { abstract setMasterKeyFromUrl(keyConnectorUrl: string, userId: UserId): Promise; @@ -13,13 +15,18 @@ export abstract class KeyConnectorService { abstract migrateUser(keyConnectorUrl: string, userId: UserId): Promise; - abstract convertNewSsoUserToKeyConnector( - tokenResponse: IdentityTokenResponse, - orgId: string, - userId: UserId, - ): Promise; + abstract convertNewSsoUserToKeyConnector(userId: UserId): Promise; abstract setUsesKeyConnector(enabled: boolean, userId: UserId): Promise; + abstract setNewSsoUserKeyConnectorConversionData( + conversion: NewSsoUserKeyConnectorConversion, + userId: UserId, + ): Promise; + + abstract requiresDomainConfirmation$( + userId: UserId, + ): Observable; + abstract convertAccountRequired$: Observable; } diff --git a/libs/common/src/key-management/key-connector/models/key-connector-domain-confirmation.ts b/libs/common/src/key-management/key-connector/models/key-connector-domain-confirmation.ts new file mode 100644 index 00000000000..277057485c1 --- /dev/null +++ b/libs/common/src/key-management/key-connector/models/key-connector-domain-confirmation.ts @@ -0,0 +1,3 @@ +export interface KeyConnectorDomainConfirmation { + keyConnectorUrl: string; +} diff --git a/libs/common/src/key-management/key-connector/models/new-sso-user-key-connector-conversion.ts b/libs/common/src/key-management/key-connector/models/new-sso-user-key-connector-conversion.ts new file mode 100644 index 00000000000..12996747c96 --- /dev/null +++ b/libs/common/src/key-management/key-connector/models/new-sso-user-key-connector-conversion.ts @@ -0,0 +1,9 @@ +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { KdfConfig } from "@bitwarden/key-management"; + +export interface NewSsoUserKeyConnectorConversion { + kdfConfig: KdfConfig; + keyConnectorUrl: string; + organizationId: string; +} diff --git a/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts b/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts index 67961616034..bb458ff49f4 100644 --- a/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts +++ b/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts @@ -3,16 +3,17 @@ import { firstValueFrom, of, timeout, TimeoutError } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; +import { SetKeyConnectorKeyRequest } from "@bitwarden/common/key-management/key-connector/models/set-key-connector-key.request"; +import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { KdfType, KeyService } from "@bitwarden/key-management"; +import { Argon2KdfConfig, PBKDF2KdfConfig, KeyService, KdfType } from "@bitwarden/key-management"; import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; import { ApiService } from "../../../abstractions/api.service"; import { OrganizationData } from "../../../admin-console/models/data/organization.data"; import { Organization } from "../../../admin-console/models/domain/organization"; import { ProfileOrganizationResponse } from "../../../admin-console/models/response/profile-organization.response"; -import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response"; import { KeyConnectorUserKeyResponse } from "../../../auth/models/response/key-connector-user-key.response"; import { TokenService } from "../../../auth/services/token.service"; import { LogService } from "../../../platform/abstractions/log.service"; @@ -24,8 +25,13 @@ import { KeyGenerationService } from "../../crypto"; import { EncString } from "../../crypto/models/enc-string"; import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service"; import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request"; +import { NewSsoUserKeyConnectorConversion } from "../models/new-sso-user-key-connector-conversion"; -import { USES_KEY_CONNECTOR, KeyConnectorService } from "./key-connector.service"; +import { + USES_KEY_CONNECTOR, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + KeyConnectorService, +} from "./key-connector.service"; describe("KeyConnectorService", () => { let keyConnectorService: KeyConnectorService; @@ -36,6 +42,7 @@ describe("KeyConnectorService", () => { const logService = mock(); const organizationService = mock(); const keyGenerationService = mock(); + const logoutCallback = jest.fn(); let stateProvider: FakeStateProvider; @@ -51,6 +58,12 @@ describe("KeyConnectorService", () => { const keyConnectorUrl = "https://key-connector-url.com"; + const conversion: NewSsoUserKeyConnectorConversion = { + kdfConfig: new PBKDF2KdfConfig(600_000), + keyConnectorUrl, + organizationId: mockOrgId, + }; + beforeEach(() => { jest.resetAllMocks(); @@ -67,7 +80,7 @@ describe("KeyConnectorService", () => { logService, organizationService, keyGenerationService, - async () => {}, + logoutCallback, stateProvider, ); }); @@ -406,28 +419,21 @@ describe("KeyConnectorService", () => { }); describe("convertNewSsoUserToKeyConnector", () => { - const tokenResponse = mock(); const passwordKey = new SymmetricCryptoKey(new Uint8Array(64)); const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; const mockEmail = "test@example.com"; const mockMasterKey = getMockMasterKey(); + const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [ + string, + EncString, + ]; let mockMakeUserKeyResult: [UserKey, EncString]; beforeEach(() => { const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; - const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [ - string, - EncString, - ]; const encString = new EncString("mockEncryptedString"); mockMakeUserKeyResult = [mockUserKey, encString] as [UserKey, EncString]; - tokenResponse.kdf = KdfType.PBKDF2_SHA256; - tokenResponse.kdfIterations = 100000; - tokenResponse.kdfMemory = 16; - tokenResponse.kdfParallelism = 4; - tokenResponse.keyConnectorUrl = keyConnectorUrl; - keyGenerationService.createKey.mockResolvedValue(passwordKey); keyService.makeMasterKey.mockResolvedValue(mockMasterKey); keyService.makeUserKey.mockResolvedValue(mockMakeUserKeyResult); @@ -435,56 +441,85 @@ describe("KeyConnectorService", () => { tokenService.getEmail.mockResolvedValue(mockEmail); }); - it("sets up a new SSO user with key connector", async () => { - await keyConnectorService.convertNewSsoUserToKeyConnector( - tokenResponse, - mockOrgId, - mockUserId, - ); + it.each([ + [KdfType.PBKDF2_SHA256, 700_000, undefined, undefined], + [KdfType.Argon2id, 11, 65, 5], + ])( + "sets up a new SSO user with key connector", + async (kdfType, kdfIterations, kdfMemory, kdfParallelism) => { + const expectedKdfConfig = + kdfType == KdfType.PBKDF2_SHA256 + ? new PBKDF2KdfConfig(kdfIterations) + : new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism); - expect(keyGenerationService.createKey).toHaveBeenCalledWith(512); - expect(keyService.makeMasterKey).toHaveBeenCalledWith( - passwordKey.keyB64, - mockEmail, - expect.any(Object), - ); - expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( - mockMasterKey, - mockUserId, - ); - expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey); - expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId); - expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( - mockMakeUserKeyResult[1], - mockUserId, - ); - expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]); - expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( - tokenResponse.keyConnectorUrl, - expect.any(KeyConnectorUserKeyRequest), - ); - expect(apiService.postSetKeyConnectorKey).toHaveBeenCalled(); - }); + const conversion: NewSsoUserKeyConnectorConversion = { + kdfConfig: expectedKdfConfig, + keyConnectorUrl: keyConnectorUrl, + organizationId: mockOrgId, + }; + const conversionState = stateProvider.singleUser.getFake( + mockUserId, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + ); + conversionState.nextState(conversion); + + await keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId); + + expect(keyGenerationService.createKey).toHaveBeenCalledWith(512); + expect(keyService.makeMasterKey).toHaveBeenCalledWith( + passwordKey.keyB64, + mockEmail, + expectedKdfConfig, + ); + expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( + mockMasterKey, + mockUserId, + ); + expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey); + expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId); + expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + mockMakeUserKeyResult[1], + mockUserId, + ); + expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]); + expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( + keyConnectorUrl, + new KeyConnectorUserKeyRequest( + Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey), + ), + ); + expect(apiService.postSetKeyConnectorKey).toHaveBeenCalledWith( + new SetKeyConnectorKeyRequest( + mockMakeUserKeyResult[1].encryptedString!, + expectedKdfConfig, + mockOrgId, + new KeysRequest(mockKeyPair[0], mockKeyPair[1].encryptedString!), + ), + ); + + // Verify that conversion data is cleared from conversionState + expect(await firstValueFrom(conversionState.state$)).toBeNull(); + }, + ); it("handles api error", async () => { apiService.postUserKeyToKeyConnector.mockRejectedValue(new Error("API error")); - try { - await keyConnectorService.convertNewSsoUserToKeyConnector( - tokenResponse, - mockOrgId, - mockUserId, - ); - } catch (error: any) { - expect(error).toBeInstanceOf(Error); - expect(error?.message).toBe("Key Connector error"); - } + const conversionState = stateProvider.singleUser.getFake( + mockUserId, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + ); + conversionState.nextState(conversion); + + await expect(keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId)).rejects.toThrow( + new Error("Key Connector error"), + ); expect(keyGenerationService.createKey).toHaveBeenCalledWith(512); expect(keyService.makeMasterKey).toHaveBeenCalledWith( passwordKey.keyB64, mockEmail, - expect.any(Object), + new PBKDF2KdfConfig(600_000), ); expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith( mockMasterKey, @@ -498,10 +533,90 @@ describe("KeyConnectorService", () => { ); expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]); expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith( - tokenResponse.keyConnectorUrl, - expect.any(KeyConnectorUserKeyRequest), + keyConnectorUrl, + new KeyConnectorUserKeyRequest(Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey)), ); expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled(); + expect(await firstValueFrom(conversionState.state$)).toEqual(conversion); + + expect(logoutCallback).toHaveBeenCalledWith("keyConnectorError"); + }); + + it("should throw error when conversion data is null", async () => { + const conversionState = stateProvider.singleUser.getFake( + mockUserId, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + ); + conversionState.nextState(null); + + await expect(keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId)).rejects.toThrow( + new Error("Key Connector conversion not found"), + ); + + // Verify that no key generation or API calls were made + expect(keyGenerationService.createKey).not.toHaveBeenCalled(); + expect(keyService.makeMasterKey).not.toHaveBeenCalled(); + expect(apiService.postUserKeyToKeyConnector).not.toHaveBeenCalled(); + expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled(); + }); + }); + + describe("setNewSsoUserKeyConnectorConversionData", () => { + it("should store Key Connector domain confirmation data in state", async () => { + const state = stateProvider.singleUser.getFake( + mockUserId, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + ); + state.nextState(null); + + await keyConnectorService.setNewSsoUserKeyConnectorConversionData(conversion, mockUserId); + + expect(await firstValueFrom(state.state$)).toEqual(conversion); + }); + + it("should overwrite existing Key Connector domain confirmation data", async () => { + const state = stateProvider.singleUser.getFake( + mockUserId, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + ); + const existingConversion: NewSsoUserKeyConnectorConversion = { + kdfConfig: new Argon2KdfConfig(3, 64, 4), + keyConnectorUrl: "https://old.example.com", + organizationId: "old-org-id" as OrganizationId, + }; + state.nextState(existingConversion); + + await keyConnectorService.setNewSsoUserKeyConnectorConversionData(conversion, mockUserId); + + expect(await firstValueFrom(state.state$)).toEqual(conversion); + }); + }); + + describe("requiresDomainConfirmation$", () => { + it("should return observable of key connector domain confirmation value when set", async () => { + const state = stateProvider.singleUser.getFake( + mockUserId, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + ); + state.nextState(conversion); + + const data$ = keyConnectorService.requiresDomainConfirmation$(mockUserId); + const data = await firstValueFrom(data$); + + expect(data).toEqual({ keyConnectorUrl: conversion.keyConnectorUrl }); + }); + + it("should return observable of null value when no data is set", async () => { + const state = stateProvider.singleUser.getFake( + mockUserId, + NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, + ); + state.nextState(null); + + const data$ = keyConnectorService.requiresDomainConfirmation$(mockUserId); + const data = await firstValueFrom(data$); + + expect(data).toBeNull(); }); }); diff --git a/libs/common/src/key-management/key-connector/services/key-connector.service.ts b/libs/common/src/key-management/key-connector/services/key-connector.service.ts index a6207ab92e2..f6730cf8870 100644 --- a/libs/common/src/key-management/key-connector/services/key-connector.service.ts +++ b/libs/common/src/key-management/key-connector/services/key-connector.service.ts @@ -1,27 +1,21 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { combineLatest, filter, firstValueFrom, Observable, of, switchMap } from "rxjs"; +import { combineLatest, filter, firstValueFrom, map, Observable, of, switchMap } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "@bitwarden/auth/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { NewSsoUserKeyConnectorConversion } from "@bitwarden/common/key-management/key-connector/models/new-sso-user-key-connector-conversion"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { - Argon2KdfConfig, - KdfConfig, - PBKDF2KdfConfig, - KeyService, - KdfType, -} from "@bitwarden/key-management"; +import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; import { ApiService } from "../../../abstractions/api.service"; import { OrganizationService } from "../../../admin-console/abstractions/organization/organization.service.abstraction"; import { OrganizationUserType } from "../../../admin-console/enums"; import { Organization } from "../../../admin-console/models/domain/organization"; import { TokenService } from "../../../auth/abstractions/token.service"; -import { IdentityTokenResponse } from "../../../auth/models/response/identity-token.response"; import { KeysRequest } from "../../../models/request/keys.request"; import { LogService } from "../../../platform/abstractions/log.service"; import { Utils } from "../../../platform/misc/utils"; @@ -32,6 +26,7 @@ import { MasterKey } from "../../../types/key"; import { KeyGenerationService } from "../../crypto"; import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service"; +import { KeyConnectorDomainConfirmation } from "../models/key-connector-domain-confirmation"; import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request"; import { SetKeyConnectorKeyRequest } from "../models/set-key-connector-key.request"; @@ -45,6 +40,27 @@ export const USES_KEY_CONNECTOR = new UserKeyDefinition( }, ); +export const NEW_SSO_USER_KEY_CONNECTOR_CONVERSION = + new UserKeyDefinition( + KEY_CONNECTOR_DISK, + "newSsoUserKeyConnectorConversion", + { + deserializer: (conversion) => + conversion == null + ? null + : { + kdfConfig: + conversion.kdfConfig.kdfType === KdfType.PBKDF2_SHA256 + ? PBKDF2KdfConfig.fromJSON(conversion.kdfConfig) + : Argon2KdfConfig.fromJSON(conversion.kdfConfig), + keyConnectorUrl: conversion.keyConnectorUrl, + organizationId: conversion.organizationId, + }, + clearOn: ["logout"], + cleanupDelayMs: 0, + }, + ); + export class KeyConnectorService implements KeyConnectorServiceAbstraction { readonly convertAccountRequired$: Observable; @@ -128,25 +144,17 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { return this.findManagingOrganization(organizations); } - async convertNewSsoUserToKeyConnector( - tokenResponse: IdentityTokenResponse, - orgId: string, - userId: UserId, - ) { - // TODO: Remove after tokenResponse.keyConnectorUrl is deprecated in 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537) - const { - kdf, - kdfIterations, - kdfMemory, - kdfParallelism, - keyConnectorUrl: legacyKeyConnectorUrl, - userDecryptionOptions, - } = tokenResponse; + async convertNewSsoUserToKeyConnector(userId: UserId) { + const conversion = await firstValueFrom( + this.stateProvider.getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId), + ); + if (conversion == null) { + throw new Error("Key Connector conversion not found"); + } + + const { kdfConfig, keyConnectorUrl, organizationId } = conversion; + const password = await this.keyGenerationService.createKey(512); - const kdfConfig: KdfConfig = - kdf === KdfType.PBKDF2_SHA256 - ? new PBKDF2KdfConfig(kdfIterations) - : new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism); const masterKey = await this.keyService.makeMasterKey( password.keyB64, @@ -165,8 +173,6 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { const [pubKey, privKey] = await this.keyService.makeKeyPair(userKey[0]); try { - const keyConnectorUrl = - legacyKeyConnectorUrl ?? userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl; await this.apiService.postUserKeyToKeyConnector(keyConnectorUrl, keyConnectorRequest); } catch (e) { this.handleKeyConnectorError(e); @@ -176,10 +182,29 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { const setPasswordRequest = new SetKeyConnectorKeyRequest( userKey[1].encryptedString, kdfConfig, - orgId, + organizationId, keys, ); await this.apiService.postSetKeyConnectorKey(setPasswordRequest); + + await this.stateProvider + .getUser(userId, NEW_SSO_USER_KEY_CONNECTOR_CONVERSION) + .update(() => null); + } + + async setNewSsoUserKeyConnectorConversionData( + conversion: NewSsoUserKeyConnectorConversion, + userId: UserId, + ): Promise { + await this.stateProvider + .getUser(userId, NEW_SSO_USER_KEY_CONNECTOR_CONVERSION) + .update(() => conversion); + } + + requiresDomainConfirmation$(userId: UserId): Observable { + return this.stateProvider + .getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId) + .pipe(map((data) => (data != null ? { keyConnectorUrl: data.keyConnectorUrl } : null))); } private handleKeyConnectorError(e: any) { diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts index 7e43ee394f6..e40b896dc8c 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts @@ -70,17 +70,17 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA // We swap these tokens from being on disk for lock actions, and in memory for logout actions // Get them here to set them to their new location after changing the timeout action and clearing if needed - const accessToken = await this.tokenService.getAccessToken(); - const refreshToken = await this.tokenService.getRefreshToken(); - const clientId = await this.tokenService.getClientId(); - const clientSecret = await this.tokenService.getClientSecret(); + const accessToken = await this.tokenService.getAccessToken(userId); + const refreshToken = await this.tokenService.getRefreshToken(userId); + const clientId = await this.tokenService.getClientId(userId); + const clientSecret = await this.tokenService.getClientSecret(userId); await this.setVaultTimeout(userId, timeout); if (timeout != VaultTimeoutStringType.Never && action === VaultTimeoutAction.LogOut) { // if we have a vault timeout and the action is log out, reset tokens // as the tokens were stored on disk and now should be stored in memory - await this.tokenService.clearTokens(); + await this.tokenService.clearTokens(userId); } await this.setVaultTimeoutAction(userId, action); diff --git a/libs/common/src/platform/misc/rxjs-operators.ts b/libs/common/src/platform/misc/rxjs-operators.ts index b3c4423c36f..6c767d3458d 100644 --- a/libs/common/src/platform/misc/rxjs-operators.ts +++ b/libs/common/src/platform/misc/rxjs-operators.ts @@ -1,4 +1,4 @@ -import { map } from "rxjs"; +import { map, Observable, OperatorFunction, Subscription } from "rxjs"; /** * An rxjs operator that extracts an object by ID from an array of objects. @@ -19,3 +19,90 @@ export const getByIds = (ids: TId[]) => { return objects.filter((o) => o.id && idSet.has(o.id)); }); }; + +/** + * A merge-like operator that takes a Set of primitives and tracks if they've been + * seen before. + * + * An emitted set that looks like `["1", "2"]` will call selector and subscribe to the resulting + * observable for both `"1"` and `"2"` but if the next emission contains just `["1"]` then the + * subscription created for `"2"` will be unsubscribed from and the observable for `"1"` will be + * left alone. If the following emission a set like `["1", "2", "3"]` then the subscription for + * `"1"` is still left alone, `"2"` has a selector called for it again, and `"3"` has a selector + * called for it the first time. If an empty set is emitted then all items are unsubscribed from. + * + * Since this operator will keep track of an observable for `n` number of items given to it. It is + * smartest to only use this on sets that you know will only get so large. + * + * *IMPORTANT NOTE* + * This observable may not be super friendly to very quick emissions/near parallel execution. + */ +export function trackedMerge( + selector: (value: T) => Observable, +): OperatorFunction, E> { + return (source: Observable>) => { + // Setup a Map to track all inner subscriptions + const tracked: Map = new Map(); + + const cleanupTracked = () => { + for (const [, trackedSub] of tracked.entries()) { + trackedSub.unsubscribe(); + } + tracked.clear(); + }; + + return new Observable((subscriber) => { + const sourceSub = source.subscribe({ + next: (values) => { + // Loop through the subscriptions we are tracking, if the new list + // doesn't have any of those values, we should clean them up. + for (const value of tracked.keys()) { + if (!values.has(value)) { + // Tracked item is no longer in the list, cleanup + tracked.get(value)?.unsubscribe(); + tracked.delete(value); + continue; + } + + // We are already tracking something for this key, remove it + values.delete(value); + } + + for (const newKey of values.keys()) { + // These are new entries, create and track subscription for them + tracked.set( + newKey, + /* eslint-disable-next-line rxjs/no-nested-subscribe */ + selector(newKey).subscribe({ + next: (innerValue) => { + subscriber.next(innerValue); + }, + error: (err: unknown) => { + // TODO: Do I need to call cleanupTracked or will calling error run my teardown logic below? + subscriber.error(err); + }, + complete: () => { + tracked.delete(newKey); + }, + }), + ); + } + }, + error: (err: unknown) => { + // TODO: Do I need to call cleanupTracked or will calling error run my teardown logic below? + subscriber.error(err); + }, + complete: () => { + // TODO: Do I need to call cleanupTracked or will calling complete run my teardown logic below? + cleanupTracked(); + subscriber.complete(); + }, + }); + + return () => { + cleanupTracked(); + sourceSub.unsubscribe(); + }; + }); + }; +} diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts new file mode 100644 index 00000000000..a70623783dc --- /dev/null +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.multiuser.spec.ts @@ -0,0 +1,313 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, bufferCount, firstValueFrom, Subject, ObservedValueOf } from "rxjs"; + +// eslint-disable-next-line no-restricted-imports +import { LogoutReason } from "@bitwarden/auth/common"; +import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; + +import { AccountService } from "../../../auth/abstractions/account.service"; +import { AuthService } from "../../../auth/abstractions/auth.service"; +import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; +import { NotificationType } from "../../../enums"; +import { NotificationResponse } from "../../../models/response/notification.response"; +import { UserId } from "../../../types/guid"; +import { AppIdService } from "../../abstractions/app-id.service"; +import { ConfigService } from "../../abstractions/config/config.service"; +import { Environment, EnvironmentService } from "../../abstractions/environment.service"; +import { LogService } from "../../abstractions/log.service"; +import { MessagingService } from "../../abstractions/messaging.service"; + +import { DefaultServerNotificationsService } from "./default-server-notifications.service"; +import { SignalRConnectionService } from "./signalr-connection.service"; +import { WebPushConnectionService, WebPushConnector } from "./webpush-connection.service"; + +describe("DefaultServerNotificationsService (multi-user)", () => { + let syncService: any; + let appIdService: MockProxy; + let environmentConfigurationService: MockProxy; + let userLogoutCallback: jest.Mock, [logoutReason: LogoutReason, userId: UserId]>; + let messagingService: MockProxy; + let accountService: MockProxy; + let signalRNotificationConnectionService: MockProxy; + let authService: MockProxy; + let webPushNotificationConnectionService: MockProxy; + let authRequestAnsweringService: MockProxy; + let configService: MockProxy; + + let activeUserAccount$: BehaviorSubject>; + let userAccounts$: BehaviorSubject>; + + let environmentConfiguration$: BehaviorSubject; + + let authenticationStatusByUser: Map>; + let webPushSupportStatusByUser: Map< + UserId, + BehaviorSubject< + { type: "supported"; service: WebPushConnector } | { type: "not-supported"; reason: string } + > + >; + + let connectionSubjectByUser: Map>; + let defaultServerNotificationsService: DefaultServerNotificationsService; + + const mockUserId1 = "user1" as UserId; + const mockUserId2 = "user2" as UserId; + + beforeEach(() => { + syncService = { + fullSync: jest.fn().mockResolvedValue(undefined), + syncUpsertCipher: jest.fn().mockResolvedValue(undefined), + syncDeleteCipher: jest.fn().mockResolvedValue(undefined), + syncUpsertFolder: jest.fn().mockResolvedValue(undefined), + syncDeleteFolder: jest.fn().mockResolvedValue(undefined), + syncUpsertSend: jest.fn().mockResolvedValue(undefined), + syncDeleteSend: jest.fn().mockResolvedValue(undefined), + }; + + appIdService = mock(); + appIdService.getAppId.mockResolvedValue("app-id"); + + environmentConfigurationService = mock(); + environmentConfiguration$ = new BehaviorSubject({ + getNotificationsUrl: () => "http://test.example.com", + } as Environment); + environmentConfigurationService.environment$ = environmentConfiguration$ as any; + // Ensure user-scoped environment lookups return the same test environment stream + environmentConfigurationService.getEnvironment$.mockImplementation( + (_userId: UserId) => environmentConfiguration$.asObservable() as any, + ); + + userLogoutCallback = jest.fn, [LogoutReason, UserId]>(); + + messagingService = mock(); + + accountService = mock(); + activeUserAccount$ = new BehaviorSubject>( + null, + ); + accountService.activeAccount$ = activeUserAccount$.asObservable(); + userAccounts$ = new BehaviorSubject>({} as any); + accountService.accounts$ = userAccounts$.asObservable(); + + signalRNotificationConnectionService = mock(); + connectionSubjectByUser = new Map(); + signalRNotificationConnectionService.connect$.mockImplementation( + (userId: UserId, _url: string) => { + if (!connectionSubjectByUser.has(userId)) { + connectionSubjectByUser.set(userId, new Subject()); + } + return connectionSubjectByUser.get(userId)!.asObservable(); + }, + ); + + authService = mock(); + authenticationStatusByUser = new Map(); + authService.authStatusFor$.mockImplementation((userId: UserId) => { + if (!authenticationStatusByUser.has(userId)) { + authenticationStatusByUser.set( + userId, + new BehaviorSubject(AuthenticationStatus.LoggedOut), + ); + } + return authenticationStatusByUser.get(userId)!.asObservable(); + }); + + webPushNotificationConnectionService = mock(); + webPushSupportStatusByUser = new Map(); + webPushNotificationConnectionService.supportStatus$.mockImplementation((userId: UserId) => { + if (!webPushSupportStatusByUser.has(userId)) { + webPushSupportStatusByUser.set( + userId, + new BehaviorSubject({ type: "not-supported", reason: "init" } as any), + ); + } + return webPushSupportStatusByUser.get(userId)!.asObservable(); + }); + + authRequestAnsweringService = mock(); + + configService = mock(); + configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => { + const flagValueByFlag: Partial> = { + [FeatureFlag.InactiveUserServerNotification]: true, + }; + return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any; + }); + + defaultServerNotificationsService = new DefaultServerNotificationsService( + mock(), + syncService, + appIdService, + environmentConfigurationService, + userLogoutCallback, + messagingService, + accountService, + signalRNotificationConnectionService, + authService, + webPushNotificationConnectionService, + authRequestAnsweringService, + configService, + ); + }); + + function setActiveUserAccount(userId: UserId | null) { + if (userId == null) { + activeUserAccount$.next(null); + } else { + activeUserAccount$.next({ + id: userId, + email: "email", + name: "Test Name", + emailVerified: true, + }); + } + } + + function addUserAccount(userId: UserId) { + const currentAccounts = (userAccounts$.getValue() as Record) ?? {}; + userAccounts$.next({ + ...currentAccounts, + [userId]: { email: "email", name: "Test Name", emailVerified: true }, + } as any); + } + + function setUserUnlocked(userId: UserId) { + if (!authenticationStatusByUser.has(userId)) { + authenticationStatusByUser.set( + userId, + new BehaviorSubject(AuthenticationStatus.LoggedOut), + ); + } + authenticationStatusByUser.get(userId)!.next(AuthenticationStatus.Unlocked); + } + + function setWebPushConnectorForUser(userId: UserId) { + const webPushConnector = mock(); + const notificationSubject = new Subject(); + webPushConnector.notifications$ = notificationSubject.asObservable(); + if (!webPushSupportStatusByUser.has(userId)) { + webPushSupportStatusByUser.set( + userId, + new BehaviorSubject({ type: "supported", service: webPushConnector } as any), + ); + } else { + webPushSupportStatusByUser + .get(userId)! + .next({ type: "supported", service: webPushConnector } as any); + } + return { webPushConnector, notificationSubject } as const; + } + + it("merges notification streams from multiple users", async () => { + addUserAccount(mockUserId1); + addUserAccount(mockUserId2); + setUserUnlocked(mockUserId1); + setUserUnlocked(mockUserId2); + setActiveUserAccount(mockUserId1); + + const user1WebPush = setWebPushConnectorForUser(mockUserId1); + const user2WebPush = setWebPushConnectorForUser(mockUserId2); + + const twoNotifications = firstValueFrom( + defaultServerNotificationsService.notifications$.pipe(bufferCount(2)), + ); + + user1WebPush.notificationSubject.next( + new NotificationResponse({ type: NotificationType.SyncFolderCreate }), + ); + user2WebPush.notificationSubject.next( + new NotificationResponse({ type: NotificationType.SyncFolderDelete }), + ); + + const notificationResults = await twoNotifications; + expect(notificationResults.length).toBe(2); + const [notification1, userA] = notificationResults[0]; + const [notification2, userB] = notificationResults[1]; + expect(userA === mockUserId1 || userA === mockUserId2).toBe(true); + expect(userB === mockUserId1 || userB === mockUserId2).toBe(true); + expect([NotificationType.SyncFolderCreate, NotificationType.SyncFolderDelete]).toContain( + notification1.type, + ); + expect([NotificationType.SyncFolderCreate, NotificationType.SyncFolderDelete]).toContain( + notification2.type, + ); + }); + + it("processes allowed multi-user notifications for non-active users (AuthRequest)", async () => { + addUserAccount(mockUserId1); + addUserAccount(mockUserId2); + setUserUnlocked(mockUserId1); + setUserUnlocked(mockUserId2); + setActiveUserAccount(mockUserId1); + + // Force SignalR path for user2 + if (!webPushSupportStatusByUser.has(mockUserId2)) { + webPushSupportStatusByUser.set( + mockUserId2, + new BehaviorSubject({ type: "not-supported", reason: "test" } as any), + ); + } else { + webPushSupportStatusByUser + .get(mockUserId2)! + .next({ type: "not-supported", reason: "test" } as any); + } + + // TODO: When PM-14943 goes in, uncomment + // authRequestAnsweringService.receivedPendingAuthRequest.mockResolvedValue(undefined as any); + + const subscription = defaultServerNotificationsService.startListening(); + + // Emit via SignalR connect$ for user2 + connectionSubjectByUser.get(mockUserId2)!.next({ + type: "ReceiveMessage", + message: new NotificationResponse({ + type: NotificationType.AuthRequest, + payload: { id: "auth-id-2", userId: mockUserId2 }, + }), + }); + + // allow async queue to drain + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", { + notificationId: "auth-id-2", + }); + + // TODO: When PM-14943 goes in, uncomment + // expect(authRequestAnsweringService.receivedPendingAuthRequest).toHaveBeenCalledWith( + // mockUserId2, + // "auth-id-2", + // ); + + subscription.unsubscribe(); + }); + + it("does not process restricted notification types for non-active users", async () => { + addUserAccount(mockUserId1); + addUserAccount(mockUserId2); + setUserUnlocked(mockUserId1); + setUserUnlocked(mockUserId2); + setActiveUserAccount(mockUserId1); + + const user1WebPush = setWebPushConnectorForUser(mockUserId1); + const user2WebPush = setWebPushConnectorForUser(mockUserId2); + + const subscription = defaultServerNotificationsService.startListening(); + + // Emit a folder create for non-active user (should be ignored) + user2WebPush.notificationSubject.next( + new NotificationResponse({ type: NotificationType.SyncFolderCreate }), + ); + // Emit a folder create for active user (should be processed) + user1WebPush.notificationSubject.next( + new NotificationResponse({ type: NotificationType.SyncFolderCreate }), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(syncService.syncUpsertFolder).toHaveBeenCalledTimes(1); + + subscription.unsubscribe(); + }); +}); diff --git a/libs/common/src/platform/server-notifications/internal/default-notifications.service.spec.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts similarity index 94% rename from libs/common/src/platform/server-notifications/internal/default-notifications.service.spec.ts rename to libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts index 2d12027e19f..a7b608f5b56 100644 --- a/libs/common/src/platform/server-notifications/internal/default-notifications.service.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.spec.ts @@ -1,11 +1,10 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, Subject } from "rxjs"; +import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, of, Subject } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "@bitwarden/auth/common"; import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { awaitAsync } from "../../../../spec"; import { Matrix } from "../../../../spec/matrix"; @@ -45,6 +44,7 @@ describe("NotificationsService", () => { let configService: MockProxy; let activeAccount: BehaviorSubject>; + let accounts: BehaviorSubject>; let environment: BehaviorSubject>; @@ -73,21 +73,23 @@ describe("NotificationsService", () => { configService = mock(); // For these tests, use the active-user implementation (feature flag disabled) - configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => { - const flagValueByFlag: Partial> = { - [FeatureFlag.PushNotificationsWhenLocked]: true, - }; - return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any; - }); + configService.getFeatureFlag$.mockImplementation(() => of(true)); activeAccount = new BehaviorSubject>(null); accountService.activeAccount$ = activeAccount.asObservable(); + accounts = new BehaviorSubject>({} as any); + accountService.accounts$ = accounts.asObservable(); + environment = new BehaviorSubject>({ getNotificationsUrl: () => "https://notifications.bitwarden.com", } as Environment); environmentService.environment$ = environment; + // Ensure user-scoped environment lookups return the same test environment stream + environmentService.getEnvironment$.mockImplementation( + (_userId: UserId) => environment.asObservable() as any, + ); authStatusGetter = Matrix.autoMockMethod( authService.authStatusFor$, @@ -130,8 +132,14 @@ describe("NotificationsService", () => { function emitActiveUser(userId: UserId | null) { if (userId == null) { activeAccount.next(null); + accounts.next({} as any); } else { activeAccount.next({ id: userId, email: "email", name: "Test Name", emailVerified: true }); + const current = (accounts.getValue() as Record) ?? {}; + accounts.next({ + ...current, + [userId]: { email: "email", name: "Test Name", emailVerified: true }, + } as any); } } diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts index 89e88d645c6..6d0728ab65d 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts @@ -17,8 +17,9 @@ import { import { LogoutReason } from "@bitwarden/auth/common"; import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { trackedMerge } from "@bitwarden/common/platform/misc"; -import { AccountService } from "../../../auth/abstractions/account.service"; +import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; import { NotificationType } from "../../../enums"; @@ -43,6 +44,10 @@ import { WebPushConnectionService } from "./webpush-connection.service"; export const DISABLED_NOTIFICATIONS_URL = "http://-"; +export const AllowedMultiUserNotificationTypes = new Set([ + NotificationType.AuthRequest, +]); + export class DefaultServerNotificationsService implements ServerNotificationsService { notifications$: Observable; @@ -62,21 +67,48 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer private readonly authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction, private readonly configService: ConfigService, ) { - this.notifications$ = this.accountService.activeAccount$.pipe( - map((account) => account?.id), - distinctUntilChanged(), - switchMap((activeAccountId) => { - if (activeAccountId == null) { - // We don't emit server-notifications for inactive accounts currently - return EMPTY; - } + this.notifications$ = this.configService + .getFeatureFlag$(FeatureFlag.InactiveUserServerNotification) + .pipe( + distinctUntilChanged(), + switchMap((inactiveUserServerNotificationEnabled) => { + if (inactiveUserServerNotificationEnabled) { + return this.accountService.accounts$.pipe( + map((accounts: Record): Set => { + const validUserIds = Object.entries(accounts) + .filter( + ([_, accountInfo]) => accountInfo.email !== "" || accountInfo.emailVerified, + ) + .map(([userId, _]) => userId as UserId); + return new Set(validUserIds); + }), + trackedMerge((id: UserId) => { + return this.userNotifications$(id as UserId).pipe( + map( + (notification: NotificationResponse) => [notification, id as UserId] as const, + ), + ); + }), + ); + } - return this.userNotifications$(activeAccountId).pipe( - map((notification) => [notification, activeAccountId] as const), - ); - }), - share(), // Multiple subscribers should only create a single connection to the server - ); + return this.accountService.activeAccount$.pipe( + map((account) => account?.id), + distinctUntilChanged(), + switchMap((activeAccountId) => { + if (activeAccountId == null) { + // We don't emit server-notifications for inactive accounts currently + return EMPTY; + } + + return this.userNotifications$(activeAccountId).pipe( + map((notification) => [notification, activeAccountId] as const), + ); + }), + ); + }), + share(), // Multiple subscribers should only create a single connection to the server + ); } /** @@ -84,7 +116,7 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer * @param userId The user id of the user to get the push server notifications for. */ private userNotifications$(userId: UserId) { - return this.environmentService.environment$.pipe( + return this.environmentService.getEnvironment$(userId).pipe( map((env) => env.getNotificationsUrl()), distinctUntilChanged(), switchMap((notificationsUrl) => { @@ -140,6 +172,7 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer private hasAccessToken$(userId: UserId) { return this.configService.getFeatureFlag$(FeatureFlag.PushNotificationsWhenLocked).pipe( + distinctUntilChanged(), switchMap((featureFlagEnabled) => { if (featureFlagEnabled) { return this.authService.authStatusFor$(userId).pipe( @@ -171,6 +204,21 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer return; } + if ( + await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.InactiveUserServerNotification), + ) + ) { + const activeAccountId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + + const isActiveUser = activeAccountId === userId; + if (!isActiveUser && !AllowedMultiUserNotificationTypes.has(notification.type)) { + return; + } + } + switch (notification.type) { case NotificationType.SyncCipherCreate: case NotificationType.SyncCipherUpdate: @@ -258,11 +306,23 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer startListening() { return this.notifications$ .pipe( - mergeMap(async ([notification, userId]) => this.processNotification(notification, userId)), + mergeMap(async ([notification, userId]) => { + try { + await this.processNotification(notification, userId); + } catch (err: unknown) { + this.logService.error( + `Problem processing notification of type ${notification.type}`, + err, + ); + } + }), ) .subscribe({ - error: (e: unknown) => - this.logService.warning("Error in server notifications$ observable", e), + error: (err: unknown) => + this.logService.error( + "Fatal error in server notifications$ observable, notifications won't be recieved anymore.", + err, + ), }); } diff --git a/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts b/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts index 58d6311c668..5998668f138 100644 --- a/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts +++ b/libs/common/src/platform/server-notifications/internal/signalr-connection.service.ts @@ -78,7 +78,7 @@ export class SignalRConnectionService { return new Observable((subsciber) => { const connection = this.hubConnectionBuilderFactory() .withUrl(notificationsUrl + "/hub", { - accessTokenFactory: () => this.apiService.getActiveBearerToken(), + accessTokenFactory: () => this.apiService.getActiveBearerToken(userId), skipNegotiation: true, transport: HttpTransportType.WebSockets, }) diff --git a/libs/common/src/platform/server-notifications/internal/web-push-notifications-api.service.ts b/libs/common/src/platform/server-notifications/internal/web-push-notifications-api.service.ts index 891dab2c069..861835c086d 100644 --- a/libs/common/src/platform/server-notifications/internal/web-push-notifications-api.service.ts +++ b/libs/common/src/platform/server-notifications/internal/web-push-notifications-api.service.ts @@ -1,3 +1,5 @@ +import { UserId } from "@bitwarden/user-core"; + import { ApiService } from "../../../abstractions/api.service"; import { AppIdService } from "../../abstractions/app-id.service"; @@ -12,13 +14,13 @@ export class WebPushNotificationsApiService { /** * Posts a device-user association to the server and ensures it's installed for push server notifications */ - async putSubscription(pushSubscription: PushSubscriptionJSON): Promise { + async putSubscription(pushSubscription: PushSubscriptionJSON, userId: UserId): Promise { const request = WebPushRequest.from(pushSubscription); await this.apiService.send( "POST", `/devices/identifier/${await this.appIdService.getAppId()}/web-push-auth`, request, - true, + userId, false, ); } diff --git a/libs/common/src/platform/server-notifications/internal/worker-webpush-connection.service.ts b/libs/common/src/platform/server-notifications/internal/worker-webpush-connection.service.ts index d8a2c33568e..8b38ebd5b17 100644 --- a/libs/common/src/platform/server-notifications/internal/worker-webpush-connection.service.ts +++ b/libs/common/src/platform/server-notifications/internal/worker-webpush-connection.service.ts @@ -143,7 +143,7 @@ class MyWebPushConnector implements WebPushConnector { await subscriptionUsersState.update(() => subscriptionUsers); // Inform the server about the new subscription-user association - await this.webPushApiService.putSubscription(subscription.toJSON()); + await this.webPushApiService.putSubscription(subscription.toJSON(), this.userId); }), switchMap(() => this.pushEvent$), map((e) => { diff --git a/libs/common/src/platform/services/config/config-api.service.ts b/libs/common/src/platform/services/config/config-api.service.ts index b7ecb9c8712..752a0075346 100644 --- a/libs/common/src/platform/services/config/config-api.service.ts +++ b/libs/common/src/platform/services/config/config-api.service.ts @@ -1,22 +1,21 @@ import { ApiService } from "../../../abstractions/api.service"; -import { TokenService } from "../../../auth/abstractions/token.service"; import { UserId } from "../../../types/guid"; import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction"; import { ServerConfigResponse } from "../../models/response/server-config.response"; export class ConfigApiService implements ConfigApiServiceAbstraction { - constructor( - private apiService: ApiService, - private tokenService: TokenService, - ) {} + constructor(private apiService: ApiService) {} async get(userId: UserId | null): Promise { // Authentication adds extra context to config responses, if the user has an access token, we want to use it // We don't particularly care about ensuring the token is valid and not expired, just that it exists - const authed: boolean = - userId == null ? false : (await this.tokenService.getAccessToken(userId)) != null; + let r: any; + if (userId == null) { + r = await this.apiService.send("GET", "/config", null, false, true); + } else { + r = await this.apiService.send("GET", "/config", null, userId, true); + } - const r = await this.apiService.send("GET", "/config", null, authed, true); return new ServerConfigResponse(r); } } diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts index 2b94305f0ec..2f6c32aa78d 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.spec.ts @@ -1,6 +1,7 @@ import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, firstValueFrom, of } from "rxjs"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; @@ -41,6 +42,7 @@ describe("DefaultSdkService", () => { let platformUtilsService!: MockProxy; let kdfConfigService!: MockProxy; let keyService!: MockProxy; + let configService!: MockProxy; let service!: DefaultSdkService; let accountService!: FakeAccountService; let fakeStateProvider!: FakeStateProvider; @@ -56,6 +58,9 @@ describe("DefaultSdkService", () => { const mockUserId = Utils.newGuid() as UserId; accountService = mockAccountServiceWith(mockUserId); fakeStateProvider = new FakeStateProvider(accountService); + configService = mock(); + + configService.serverConfig$ = new BehaviorSubject(null); // Can't use `of(mock())` for some reason environmentService.environment$ = new BehaviorSubject(mock()); @@ -68,6 +73,7 @@ describe("DefaultSdkService", () => { kdfConfigService, keyService, fakeStateProvider, + configService, ); }); diff --git a/libs/common/src/platform/services/sdk/default-sdk.service.ts b/libs/common/src/platform/services/sdk/default-sdk.service.ts index 9d965acb44b..2713aaf8f4b 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -12,8 +12,10 @@ import { of, takeWhile, throwIfEmpty, + firstValueFrom, } from "rxjs"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService, KdfConfigService, KdfConfig, KdfType } from "@bitwarden/key-management"; @@ -25,10 +27,9 @@ import { UnsignedSharedKey, } from "@bitwarden/sdk-internal"; -import { EncryptedOrganizationKeyData } from "../../../admin-console/models/data/encrypted-organization-key.data"; import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service"; import { DeviceType } from "../../../enums/device-type.enum"; -import { EncryptedString } from "../../../key-management/crypto/models/enc-string"; +import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string"; import { OrganizationId, UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; import { Environment, EnvironmentService } from "../../abstractions/environment.service"; @@ -67,7 +68,9 @@ export class DefaultSdkService implements SdkService { concatMap(async (env) => { await SdkLoadService.Ready; const settings = this.toSettings(env); - return await this.sdkClientFactory.createSdkClient(new JsTokenProvider(), settings); + const client = await this.sdkClientFactory.createSdkClient(new JsTokenProvider(), settings); + await this.loadFeatureFlags(client); + return client; }), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -85,6 +88,7 @@ export class DefaultSdkService implements SdkService { private kdfConfigService: KdfConfigService, private keyService: KeyService, private stateProvider: StateProvider, + private configService: ConfigService, private userAgent: string | null = null, ) {} @@ -215,7 +219,7 @@ export class DefaultSdkService implements SdkService { kdfParams: KdfConfig, privateKey: EncryptedString, userKey: UserKey, - orgKeys: Record | null, + orgKeys: Record, ) { await client.crypto().initialize_user_crypto({ userId: asUuid(userId), @@ -240,14 +244,26 @@ export class DefaultSdkService implements SdkService { // null to make sure any existing org keys are cleared. await client.crypto().initialize_org_crypto({ organizationKeys: new Map( - Object.entries(orgKeys ?? {}) - .filter(([_, v]) => v.type === "organization") - .map(([k, v]) => [asUuid(k), v.key as UnsignedSharedKey]), + Object.entries(orgKeys).map(([k, v]) => [asUuid(k), v.toJSON() as UnsignedSharedKey]), ), }); // Initialize the SDK managed database and the client managed repositories. await initializeState(userId, client.platform().state(), this.stateProvider); + + await this.loadFeatureFlags(client); + } + + private async loadFeatureFlags(client: BitwardenClient) { + const serverConfig = await firstValueFrom(this.configService.serverConfig$); + + const featureFlagMap = new Map( + Object.entries(serverConfig?.featureStates ?? {}) + .filter(([, value]) => typeof value === "boolean") // The SDK only supports boolean feature flags at this time + .map(([key, value]) => [key, value] as [string, boolean]), + ); + + client.platform().load_flags(featureFlagMap); } private toSettings(env: Environment): ClientSettings { diff --git a/libs/common/src/services/api.service.spec.ts b/libs/common/src/services/api.service.spec.ts index fffe0478254..144b0cc02c4 100644 --- a/libs/common/src/services/api.service.spec.ts +++ b/libs/common/src/services/api.service.spec.ts @@ -1,13 +1,19 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { of } from "rxjs"; +import { ObservedValueOf, of } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "@bitwarden/auth/common"; +import { UserId } from "@bitwarden/user-core"; +import { AccountService } from "../auth/abstractions/account.service"; import { TokenService } from "../auth/abstractions/token.service"; import { DeviceType } from "../enums"; -import { VaultTimeoutSettingsService } from "../key-management/vault-timeout"; +import { + VaultTimeoutAction, + VaultTimeoutSettingsService, + VaultTimeoutStringType, +} from "../key-management/vault-timeout"; import { ErrorResponse } from "../models/response/error.response"; import { AppIdService } from "../platform/abstractions/app-id.service"; import { Environment, EnvironmentService } from "../platform/abstractions/environment.service"; @@ -25,10 +31,14 @@ describe("ApiService", () => { let logService: MockProxy; let logoutCallback: jest.Mock, [reason: LogoutReason]>; let vaultTimeoutSettingsService: MockProxy; + let accountService: MockProxy; let httpOperations: MockProxy; let sut: ApiService; + const testActiveUser = "activeUser" as UserId; + const testInactiveUser = "inactiveUser" as UserId; + beforeEach(() => { tokenService = mock(); platformUtilsService = mock(); @@ -40,6 +50,15 @@ describe("ApiService", () => { logService = mock(); logoutCallback = jest.fn(); vaultTimeoutSettingsService = mock(); + accountService = mock(); + + accountService.activeAccount$ = of({ + id: testActiveUser, + email: "user1@example.com", + emailVerified: true, + name: "Test Name", + } satisfies ObservedValueOf); + httpOperations = mock(); sut = new ApiService( @@ -51,6 +70,7 @@ describe("ApiService", () => { logService, logoutCallback, vaultTimeoutSettingsService, + accountService, httpOperations, "custom-user-agent", ); @@ -62,6 +82,12 @@ describe("ApiService", () => { getApiUrl: () => "https://example.com", } satisfies Partial as Environment); + environmentService.getEnvironment$.mockReturnValue( + of({ + getApiUrl: () => "https://authed.example.com", + } satisfies Partial as Environment), + ); + httpOperations.createRequest.mockImplementation((url, request) => { return { url: url, @@ -96,6 +122,7 @@ describe("ApiService", () => { expect(nativeFetch).toHaveBeenCalledTimes(1); const request = nativeFetch.mock.calls[0][0]; + expect(request.url).toBe("https://authed.example.com/something"); // This should get set for users of send expect(request.cache).toBe("no-store"); // TODO: Could expect on the credentials parameter @@ -109,6 +136,185 @@ describe("ApiService", () => { // The response body expect(response).toEqual({ hello: "world" }); }); + + it("authenticates with non-active user when user is passed in", async () => { + environmentService.environment$ = of({ + getApiUrl: () => "https://example.com", + } satisfies Partial as Environment); + + environmentService.getEnvironment$.calledWith(testInactiveUser).mockReturnValueOnce( + of({ + getApiUrl: () => "https://inactive.example.com", + } satisfies Partial as Environment), + ); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + tokenService.getAccessToken + .calledWith(testInactiveUser) + .mockResolvedValue("inactive_access_token"); + + tokenService.tokenNeedsRefresh.calledWith(testInactiveUser).mockResolvedValue(false); + + const nativeFetch = jest.fn, [request: Request]>(); + + nativeFetch.mockImplementation((request) => { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ hello: "world" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + }); + + sut.nativeFetch = nativeFetch; + + const response = await sut.send( + "GET", + "/something", + null, + testInactiveUser, + true, + null, + null, + ); + + expect(nativeFetch).toHaveBeenCalledTimes(1); + const request = nativeFetch.mock.calls[0][0]; + expect(request.url).toBe("https://inactive.example.com/something"); + // This should get set for users of send + expect(request.cache).toBe("no-store"); + // TODO: Could expect on the credentials parameter + expect(request.headers.get("Device-Type")).toBe("2"); // Chrome Extension + // Custom user agent should get set + expect(request.headers.get("User-Agent")).toBe("custom-user-agent"); + // This should be set when the caller has indicated there is a response + expect(request.headers.get("Accept")).toBe("application/json"); + // If they have indicated that it's authed, then the authorization header should get set. + expect(request.headers.get("Authorization")).toBe("Bearer inactive_access_token"); + // The response body + expect(response).toEqual({ hello: "world" }); + }); + + const cases: { + name: string; + authedOrUserId: boolean | UserId; + expectedEffectiveUser: UserId; + }[] = [ + { + name: "refreshes active user when true passed in for auth", + authedOrUserId: true, + expectedEffectiveUser: testActiveUser, + }, + { + name: "refreshes acess token when the user passed in happens to be the active one", + authedOrUserId: testActiveUser, + expectedEffectiveUser: testActiveUser, + }, + { + name: "refreshes access token when the user passed in happens to be inactive", + authedOrUserId: testInactiveUser, + expectedEffectiveUser: testInactiveUser, + }, + ]; + + it.each(cases)("$name does", async ({ authedOrUserId, expectedEffectiveUser }) => { + environmentService.getEnvironment$.calledWith(expectedEffectiveUser).mockReturnValue( + of({ + getApiUrl: () => `https://${expectedEffectiveUser}.example.com`, + getIdentityUrl: () => `https://${expectedEffectiveUser}.identity.example.com`, + } satisfies Partial as Environment), + ); + + tokenService.getAccessToken + .calledWith(expectedEffectiveUser) + .mockResolvedValue(`${expectedEffectiveUser}_access_token`); + + tokenService.tokenNeedsRefresh.calledWith(expectedEffectiveUser).mockResolvedValue(true); + + tokenService.getRefreshToken + .calledWith(expectedEffectiveUser) + .mockResolvedValue(`${expectedEffectiveUser}_refresh_token`); + + tokenService.decodeAccessToken + .calledWith(expectedEffectiveUser) + .mockResolvedValue({ client_id: "web" }); + + tokenService.decodeAccessToken + .calledWith(`${expectedEffectiveUser}_new_access_token`) + .mockResolvedValue({ sub: expectedEffectiveUser }); + + vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$ + .calledWith(expectedEffectiveUser) + .mockReturnValue(of(VaultTimeoutAction.Lock)); + + vaultTimeoutSettingsService.getVaultTimeoutByUserId$ + .calledWith(expectedEffectiveUser) + .mockReturnValue(of(VaultTimeoutStringType.Never)); + + tokenService.setTokens + .calledWith( + `${expectedEffectiveUser}_new_access_token`, + VaultTimeoutAction.Lock, + VaultTimeoutStringType.Never, + `${expectedEffectiveUser}_new_refresh_token`, + ) + .mockResolvedValue({ accessToken: `${expectedEffectiveUser}_refreshed_access_token` }); + + httpOperations.createRequest.mockImplementation((url, request) => { + return { + url: url, + cache: request.cache, + credentials: request.credentials, + method: request.method, + mode: request.mode, + signal: request.signal, + headers: new Headers(request.headers), + } satisfies Partial as unknown as Request; + }); + + const nativeFetch = jest.fn, [request: Request]>(); + + nativeFetch.mockImplementation((request) => { + if (request.url.includes("identity")) { + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + access_token: `${expectedEffectiveUser}_new_access_token`, + refresh_token: `${expectedEffectiveUser}_new_refresh_token`, + }), + } satisfies Partial as Response); + } + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ hello: "world" }), + headers: new Headers({ + "content-type": "application/json", + }), + } satisfies Partial as Response); + }); + + sut.nativeFetch = nativeFetch; + + await sut.send("GET", "/something", null, authedOrUserId, true, null, null); + + expect(nativeFetch).toHaveBeenCalledTimes(2); + }); }); const errorData: { @@ -169,9 +375,11 @@ describe("ApiService", () => { it.each(errorData)( "throws error-like response when not ok response with $name", async ({ input, error }) => { - environmentService.environment$ = of({ - getApiUrl: () => "https://example.com", - } satisfies Partial as Environment); + environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue( + of({ + getApiUrl: () => "https://example.com", + } satisfies Partial as Environment), + ); httpOperations.createRequest.mockImplementation((url, request) => { return { diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 6a670368b1f..bbf990122df 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, map } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -47,6 +47,7 @@ import { ProviderUserUserDetailsResponse, } from "../admin-console/models/response/provider/provider-user.response"; import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response"; +import { AccountService } from "../auth/abstractions/account.service"; import { TokenService } from "../auth/abstractions/token.service"; import { DeviceVerificationRequest } from "../auth/models/request/device-verification.request"; import { DisableTwoFactorAuthenticatorRequest } from "../auth/models/request/disable-two-factor-authenticator.request"; @@ -121,7 +122,7 @@ import { ListResponse } from "../models/response/list.response"; import { ProfileResponse } from "../models/response/profile.response"; import { UserKeyResponse } from "../models/response/user-key.response"; import { AppIdService } from "../platform/abstractions/app-id.service"; -import { EnvironmentService } from "../platform/abstractions/environment.service"; +import { Environment, EnvironmentService } from "../platform/abstractions/environment.service"; import { LogService } from "../platform/abstractions/log.service"; import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; import { flagEnabled } from "../platform/misc/flags"; @@ -155,7 +156,7 @@ export type HttpOperations = { export class ApiService implements ApiServiceAbstraction { private device: DeviceType; private deviceType: string; - private refreshTokenPromise: Promise | undefined; + private refreshTokenPromise: Record> = {}; /** * The message (responseJson.ErrorModel.Message) that comes back from the server when a new device verification is required. @@ -172,6 +173,7 @@ export class ApiService implements ApiServiceAbstraction { private logService: LogService, private logoutCallback: (logoutReason: LogoutReason) => Promise, private vaultTimeoutSettingsService: VaultTimeoutSettingsService, + private readonly accountService: AccountService, private readonly httpOperations: HttpOperations, private customUserAgent: string = null, ) { @@ -209,7 +211,7 @@ export class ApiService implements ApiServiceAbstraction { const response = await this.fetch( this.httpOperations.createRequest(env.getIdentityUrl() + "/connect/token", { body: this.qsStringify(identityToken), - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), cache: "no-store", headers: headers, method: "POST", @@ -241,9 +243,13 @@ export class ApiService implements ApiServiceAbstraction { return Promise.reject(new ErrorResponse(responseJson, response.status, true)); } - async refreshIdentityToken(): Promise { + async refreshIdentityToken(userId: UserId | null = null): Promise { + const normalizedUser = (userId ??= await this.getActiveUser()); + if (normalizedUser == null) { + throw new Error("No user provided and no active user, cannot refresh the identity token."); + } try { - await this.refreshToken(); + await this.refreshToken(normalizedUser); } catch (e) { this.logService.error("Error refreshing access token: ", e); throw e; @@ -1398,11 +1404,16 @@ export class ApiService implements ApiServiceAbstraction { if (this.customUserAgent != null) { headers.set("User-Agent", this.customUserAgent); } - const env = await firstValueFrom(this.environmentService.environment$); + + const env = await firstValueFrom( + userId == null + ? this.environmentService.environment$ + : this.environmentService.getEnvironment$(userId), + ); const response = await this.fetch( this.httpOperations.createRequest(env.getEventsUrl() + "/collect", { cache: "no-store", - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), method: "POST", body: JSON.stringify(request), headers: headers, @@ -1444,7 +1455,11 @@ export class ApiService implements ApiServiceAbstraction { async getMasterKeyFromKeyConnector( keyConnectorUrl: string, ): Promise { - const authHeader = await this.getActiveBearerToken(); + const activeUser = await this.getActiveUser(); + if (activeUser == null) { + throw new Error("No active user, cannot get master key from key connector."); + } + const authHeader = await this.getActiveBearerToken(activeUser); const response = await this.fetch( this.httpOperations.createRequest(keyConnectorUrl + "/user-keys", { @@ -1469,7 +1484,11 @@ export class ApiService implements ApiServiceAbstraction { keyConnectorUrl: string, request: KeyConnectorUserKeyRequest, ): Promise { - const authHeader = await this.getActiveBearerToken(); + const activeUser = await this.getActiveUser(); + if (activeUser == null) { + throw new Error("No active user, cannot post key to key connector."); + } + const authHeader = await this.getActiveBearerToken(activeUser); const response = await this.fetch( this.httpOperations.createRequest(keyConnectorUrl + "/user-keys", { @@ -1521,10 +1540,10 @@ export class ApiService implements ApiServiceAbstraction { // Helpers - async getActiveBearerToken(): Promise { - let accessToken = await this.tokenService.getAccessToken(); - if (await this.tokenService.tokenNeedsRefresh()) { - accessToken = await this.refreshToken(); + async getActiveBearerToken(userId: UserId): Promise { + let accessToken = await this.tokenService.getAccessToken(userId); + if (await this.tokenService.tokenNeedsRefresh(userId)) { + accessToken = await this.refreshToken(userId); } return accessToken; } @@ -1563,7 +1582,7 @@ export class ApiService implements ApiServiceAbstraction { const response = await this.fetch( this.httpOperations.createRequest(env.getIdentityUrl() + path, { cache: "no-store", - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), headers: headers, method: "GET", }), @@ -1646,26 +1665,27 @@ export class ApiService implements ApiServiceAbstraction { } // Keep the running refreshTokenPromise to prevent parallel calls. - protected refreshToken(): Promise { - if (this.refreshTokenPromise === undefined) { - this.refreshTokenPromise = this.internalRefreshToken(); - void this.refreshTokenPromise.finally(() => { - this.refreshTokenPromise = undefined; + protected refreshToken(userId: UserId): Promise { + if (this.refreshTokenPromise[userId] === undefined) { + // TODO: Have different promise for each user + this.refreshTokenPromise[userId] = this.internalRefreshToken(userId); + void this.refreshTokenPromise[userId].finally(() => { + delete this.refreshTokenPromise[userId]; }); } - return this.refreshTokenPromise; + return this.refreshTokenPromise[userId]; } - private async internalRefreshToken(): Promise { - const refreshToken = await this.tokenService.getRefreshToken(); + private async internalRefreshToken(userId: UserId): Promise { + const refreshToken = await this.tokenService.getRefreshToken(userId); if (refreshToken != null && refreshToken !== "") { - return this.refreshAccessToken(); + return await this.refreshAccessToken(userId); } - const clientId = await this.tokenService.getClientId(); - const clientSecret = await this.tokenService.getClientSecret(); + const clientId = await this.tokenService.getClientId(userId); + const clientSecret = await this.tokenService.getClientSecret(userId); if (!Utils.isNullOrWhitespace(clientId) && !Utils.isNullOrWhitespace(clientSecret)) { - return this.refreshApiToken(); + return await this.refreshApiToken(userId); } this.refreshAccessTokenErrorCallback(); @@ -1673,8 +1693,8 @@ export class ApiService implements ApiServiceAbstraction { throw new Error("Cannot refresh access token, no refresh token or api keys are stored."); } - protected async refreshAccessToken(): Promise { - const refreshToken = await this.tokenService.getRefreshToken(); + private async refreshAccessToken(userId: UserId): Promise { + const refreshToken = await this.tokenService.getRefreshToken(userId); if (refreshToken == null || refreshToken === "") { throw new Error(); } @@ -1687,8 +1707,8 @@ export class ApiService implements ApiServiceAbstraction { headers.set("User-Agent", this.customUserAgent); } - const env = await firstValueFrom(this.environmentService.environment$); - const decodedToken = await this.tokenService.decodeAccessToken(); + const env = await firstValueFrom(this.environmentService.getEnvironment$(userId)); + const decodedToken = await this.tokenService.decodeAccessToken(userId); const response = await this.fetch( this.httpOperations.createRequest(env.getIdentityUrl() + "/connect/token", { body: this.qsStringify({ @@ -1697,7 +1717,7 @@ export class ApiService implements ApiServiceAbstraction { refresh_token: refreshToken, }), cache: "no-store", - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), headers: headers, method: "POST", }), @@ -1732,9 +1752,9 @@ export class ApiService implements ApiServiceAbstraction { } } - protected async refreshApiToken(): Promise { - const clientId = await this.tokenService.getClientId(); - const clientSecret = await this.tokenService.getClientSecret(); + protected async refreshApiToken(userId: UserId): Promise { + const clientId = await this.tokenService.getClientId(userId); + const clientSecret = await this.tokenService.getClientSecret(userId); const appId = await this.appIdService.getAppId(); const deviceRequest = new DeviceRequest(appId, this.platformUtilsService); @@ -1751,7 +1771,12 @@ export class ApiService implements ApiServiceAbstraction { } const newDecodedAccessToken = await this.tokenService.decodeAccessToken(response.accessToken); - const userId = newDecodedAccessToken.sub; + + if (newDecodedAccessToken.sub !== userId) { + throw new Error( + `Token was supposed to be refreshed for ${userId} but the token we got back was for ${newDecodedAccessToken.sub}`, + ); + } const vaultTimeoutAction = await firstValueFrom( this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(userId), @@ -1772,12 +1797,28 @@ export class ApiService implements ApiServiceAbstraction { method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", path: string, body: any, - authed: boolean, + authedOrUserId: UserId | boolean, hasResponse: boolean, apiUrl?: string | null, alterHeaders?: (headers: Headers) => void, ): Promise { - const env = await firstValueFrom(this.environmentService.environment$); + if (authedOrUserId == null) { + throw new Error("A user id was given but it was null, cannot complete API request."); + } + + let userId: UserId | null = null; + if (typeof authedOrUserId === "boolean" && authedOrUserId) { + // Backwards compatible for authenticating the active user when `true` is passed in + userId = await this.getActiveUser(); + } else if (typeof authedOrUserId === "string") { + userId = authedOrUserId; + } + + const env = await firstValueFrom( + userId == null + ? this.environmentService.environment$ + : this.environmentService.getEnvironment$(userId), + ); apiUrl = Utils.isNullOrWhitespace(apiUrl) ? env.getApiUrl() : apiUrl; // Prevent directory traversal from malicious paths @@ -1786,7 +1827,7 @@ export class ApiService implements ApiServiceAbstraction { apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : ""); const [requestHeaders, requestBody] = await this.buildHeadersAndBody( - authed, + userId, hasResponse, body, alterHeaders, @@ -1794,7 +1835,7 @@ export class ApiService implements ApiServiceAbstraction { const requestInit: RequestInit = { cache: "no-store", - credentials: await this.getCredentials(), + credentials: await this.getCredentials(env), method: method, }; requestInit.headers = requestHeaders; @@ -1810,13 +1851,13 @@ export class ApiService implements ApiServiceAbstraction { } else if (hasResponse && response.status === 200 && responseIsCsv) { return await response.text(); } else if (response.status !== 200 && response.status !== 204) { - const error = await this.handleError(response, false, authed); + const error = await this.handleError(response, false, userId != null); return Promise.reject(error); } } private async buildHeadersAndBody( - authed: boolean, + userToAuthenticate: UserId | null, hasResponse: boolean, body: any, alterHeaders: (headers: Headers) => void, @@ -1838,8 +1879,8 @@ export class ApiService implements ApiServiceAbstraction { if (alterHeaders != null) { alterHeaders(headers); } - if (authed) { - const authHeader = await this.getActiveBearerToken(); + if (userToAuthenticate != null) { + const authHeader = await this.getActiveBearerToken(userToAuthenticate); headers.set("Authorization", "Bearer " + authHeader); } else { // For unauthenticated requests, we need to tell the server what the device is for flag targeting, @@ -1901,8 +1942,11 @@ export class ApiService implements ApiServiceAbstraction { .join("&"); } - private async getCredentials(): Promise { - const env = await firstValueFrom(this.environmentService.environment$); + private async getActiveUser(): Promise { + return await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))); + } + + private async getCredentials(env: Environment): Promise { if (this.platformUtilsService.getClientType() !== ClientType.Web || env.hasBaseUrl()) { return "include"; } diff --git a/libs/common/src/tools/providers.spec.ts b/libs/common/src/tools/providers.spec.ts new file mode 100644 index 00000000000..5953e5ebab2 --- /dev/null +++ b/libs/common/src/tools/providers.spec.ts @@ -0,0 +1,178 @@ +import { mock, MockProxy } from "jest-mock-extended"; + +import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction"; +import { ConfigService } from "../platform/abstractions/config/config.service"; +import { LogService } from "../platform/abstractions/log.service"; +import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; +import { StateProvider } from "../platform/state"; + +import { LegacyEncryptorProvider } from "./cryptography/legacy-encryptor-provider"; +import { ExtensionRegistry } from "./extension/extension-registry.abstraction"; +import { ExtensionService } from "./extension/extension.service"; +import { disabledSemanticLoggerProvider } from "./log"; +import { createSystemServiceProvider } from "./providers"; + +describe("SystemServiceProvider", () => { + let mockEncryptor: LegacyEncryptorProvider; + let mockState: StateProvider; + let mockPolicy: PolicyService; + let mockRegistry: ExtensionRegistry; + let mockLogger: LogService; + let mockEnvironment: MockProxy; + let mockConfigService: ConfigService; + + beforeEach(() => { + jest.resetAllMocks(); + + mockEncryptor = mock(); + mockState = mock(); + mockPolicy = mock(); + mockRegistry = mock(); + mockLogger = mock(); + mockEnvironment = mock(); + mockConfigService = mock(); + }); + + describe("createSystemServiceProvider", () => { + it("returns object with all required services when called with valid parameters", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result).toHaveProperty("policy", mockPolicy); + expect(result).toHaveProperty("extension"); + expect(result).toHaveProperty("log"); + expect(result).toHaveProperty("configService", mockConfigService); + expect(result).toHaveProperty("environment", mockEnvironment); + expect(result.extension).toBeInstanceOf(ExtensionService); + }); + + it("creates ExtensionService with correct dependencies when called", () => { + mockEnvironment.isDev.mockReturnValue(true); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.extension).toBeInstanceOf(ExtensionService); + }); + + describe("given development environment", () => { + it("uses enableLogForTypes when environment.isDev() returns true", () => { + mockEnvironment.isDev.mockReturnValue(true); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(mockEnvironment.isDev).toHaveBeenCalledTimes(1); + expect(result.log).not.toBe(disabledSemanticLoggerProvider); + }); + }); + + describe("given production environment", () => { + it("uses disabledSemanticLoggerProvider when environment.isDev() returns false", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(mockEnvironment.isDev).toHaveBeenCalledTimes(1); + expect(result.log).toBe(disabledSemanticLoggerProvider); + }); + }); + + it("configures ExtensionService with encryptor, state, log provider, and now function when called", () => { + mockEnvironment.isDev.mockReturnValue(false); + const dateSpy = jest.spyOn(Date, "now"); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.extension).toBeInstanceOf(ExtensionService); + expect(dateSpy).not.toHaveBeenCalled(); + }); + + it("passes through policy service correctly when called", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.policy).toBe(mockPolicy); + }); + + it("passes through configService correctly when called", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.configService).toBe(mockConfigService); + }); + + it("passes through environment service correctly when called", () => { + mockEnvironment.isDev.mockReturnValue(false); + + const result = createSystemServiceProvider( + mockEncryptor, + mockState, + mockPolicy, + mockRegistry, + mockLogger, + mockEnvironment, + mockConfigService, + ); + + expect(result.environment).toBe(mockEnvironment); + }); + }); +}); diff --git a/libs/common/src/tools/providers.ts b/libs/common/src/tools/providers.ts index 181df94be83..ac42c556042 100644 --- a/libs/common/src/tools/providers.ts +++ b/libs/common/src/tools/providers.ts @@ -1,10 +1,15 @@ +import { LogService } from "@bitwarden/logging"; import { BitwardenClient } from "@bitwarden/sdk-internal"; +import { StateProvider } from "@bitwarden/state"; import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction"; import { ConfigService } from "../platform/abstractions/config/config.service"; +import { PlatformUtilsService } from "../platform/abstractions/platform-utils.service"; +import { LegacyEncryptorProvider } from "./cryptography/legacy-encryptor-provider"; +import { ExtensionRegistry } from "./extension/extension-registry.abstraction"; import { ExtensionService } from "./extension/extension.service"; -import { LogProvider } from "./log"; +import { disabledSemanticLoggerProvider, enableLogForTypes, LogProvider } from "./log"; /** Provides access to commonly-used cross-cutting services. */ export type SystemServiceProvider = { @@ -20,6 +25,42 @@ export type SystemServiceProvider = { /** Config Service to determine flag features */ readonly configService: ConfigService; + /** Platform Service to inspect runtime environment */ + readonly environment: PlatformUtilsService; + /** SDK Service */ - readonly sdk: BitwardenClient; + readonly sdk?: BitwardenClient; }; + +/** Constructs a system service provider. */ +export function createSystemServiceProvider( + encryptor: LegacyEncryptorProvider, + state: StateProvider, + policy: PolicyService, + registry: ExtensionRegistry, + logger: LogService, + environment: PlatformUtilsService, + configService: ConfigService, +): SystemServiceProvider { + let log: LogProvider; + if (environment.isDev()) { + log = enableLogForTypes(logger, []); + } else { + log = disabledSemanticLoggerProvider; + } + + const extension = new ExtensionService(registry, { + encryptor, + state, + log, + now: Date.now, + }); + + return { + policy, + extension, + log, + configService, + environment, + }; +} diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 2f4fcf0ef51..7971b6d4658 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -65,12 +65,16 @@ export abstract class CipherService implements UserKeyRotationDataProvider; abstract filterCiphersForUrl( ciphers: C[], url: string, includeOtherTypes?: CipherType[], defaultMatch?: UriMatchStrategySetting, + /** When true, will override the match strategy for the cipher if it is Never. */ + overrideNeverMatchStrategy?: true, ): Promise; abstract getAllFromApiForOrganization(organizationId: string): Promise; /** @@ -253,6 +257,10 @@ export abstract class CipherService implements UserKeyRotationDataProvider; /** diff --git a/libs/common/src/vault/models/view/login-uri-view.spec.ts b/libs/common/src/vault/models/view/login-uri-view.spec.ts index 155d3d59f7c..aae9438df2e 100644 --- a/libs/common/src/vault/models/view/login-uri-view.spec.ts +++ b/libs/common/src/vault/models/view/login-uri-view.spec.ts @@ -111,6 +111,33 @@ describe("LoginUriView", () => { expect(actual).toBe(false); }); + + it("overrides Never match strategy with Domain when parameter is set", () => { + const loginUri = new LoginUriView(); + loginUri.uri = "https://example.org"; + loginUri.match = UriMatchStrategy.Never; + + expect(loginUri.matchesUri("https://example.org", new Set(), undefined, true)).toBe(true); + expect(loginUri.matchesUri("https://example.org", new Set(), undefined)).toBe(false); + }); + + it("overrides Never match strategy when passed in as default strategy", () => { + const loginUriNoMatch = new LoginUriView(); + loginUriNoMatch.uri = "https://example.org"; + + expect( + loginUriNoMatch.matchesUri( + "https://example.org", + new Set(), + UriMatchStrategy.Never, + true, + ), + ).toBe(true); + + expect( + loginUriNoMatch.matchesUri("https://example.org", new Set(), UriMatchStrategy.Never), + ).toBe(false); + }); }); describe("using host matching", () => { diff --git a/libs/common/src/vault/models/view/login-uri.view.ts b/libs/common/src/vault/models/view/login-uri.view.ts index 38cd517e542..49ac9c6278f 100644 --- a/libs/common/src/vault/models/view/login-uri.view.ts +++ b/libs/common/src/vault/models/view/login-uri.view.ts @@ -142,6 +142,8 @@ export class LoginUriView implements View { targetUri: string, equivalentDomains: Set, defaultUriMatch: UriMatchStrategySetting = null, + /** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */ + overrideNeverMatchStrategy?: true, ): boolean { if (!this.uri || !targetUri) { return false; @@ -150,6 +152,12 @@ export class LoginUriView implements View { let matchType = this.match ?? defaultUriMatch; matchType ??= UriMatchStrategy.Domain; + // Override the match strategy with `Domain` when it is `Never` and `overrideNeverMatchStrategy` is true. + // This is useful in scenarios when the cipher should be matched to rely other information other than autofill. + if (overrideNeverMatchStrategy && matchType === UriMatchStrategy.Never) { + matchType = UriMatchStrategy.Domain; + } + const targetDomain = Utils.getDomain(targetUri); const matchDomains = equivalentDomains.add(targetDomain); diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index d268cf4afaa..44c6ee8f2e9 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -82,12 +82,16 @@ export class LoginView extends ItemView { targetUri: string, equivalentDomains: Set, defaultUriMatch: UriMatchStrategySetting = null, + /** When present, will override the match strategy for the cipher if it is `Never` with `Domain` */ + overrideNeverMatchStrategy?: true, ): boolean { if (this.uris == null) { return false; } - return this.uris.some((uri) => uri.matchesUri(targetUri, equivalentDomains, defaultUriMatch)); + return this.uris.some((uri) => + uri.matchesUri(targetUri, equivalentDomains, defaultUriMatch, overrideNeverMatchStrategy), + ); } static fromJSON(obj: Partial>): LoginView { diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index d89a41aba1f..2ad4274c235 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -601,6 +601,7 @@ export class CipherService implements CipherServiceAbstraction { userId: UserId, includeOtherTypes?: CipherType[], defaultMatch: UriMatchStrategySetting = null, + overrideNeverMatchStrategy?: true, ): Promise { return await firstValueFrom( this.cipherViews$(userId).pipe( @@ -612,6 +613,7 @@ export class CipherService implements CipherServiceAbstraction { url, includeOtherTypes, defaultMatch, + overrideNeverMatchStrategy, ), ), ), @@ -623,6 +625,7 @@ export class CipherService implements CipherServiceAbstraction { url: string, includeOtherTypes?: CipherType[], defaultMatch: UriMatchStrategySetting = null, + overrideNeverMatchStrategy?: true, ): Promise { if (url == null && includeOtherTypes == null) { return []; @@ -647,7 +650,13 @@ export class CipherService implements CipherServiceAbstraction { } if (cipherIsLogin) { - return CipherViewLikeUtils.matchesUri(cipher, url, equivalentDomains, defaultMatch); + return CipherViewLikeUtils.matchesUri( + cipher, + url, + equivalentDomains, + defaultMatch, + overrideNeverMatchStrategy, + ); } return false; @@ -1535,11 +1544,13 @@ export class CipherService implements CipherServiceAbstraction { return encryptedCiphers; } + /** @inheritdoc */ async getDecryptedAttachmentBuffer( cipherId: CipherId, attachment: AttachmentView, response: Response, userId: UserId, + useLegacyDecryption?: boolean, ): Promise { const useSdkDecryption = await this.configService.getFeatureFlag( FeatureFlag.PM19941MigrateCipherDomainToSdk, @@ -1549,7 +1560,7 @@ export class CipherService implements CipherServiceAbstraction { this.ciphers$(userId).pipe(map((ciphersData) => new Cipher(ciphersData[cipherId]))), ); - if (useSdkDecryption) { + if (useSdkDecryption && !useLegacyDecryption) { const encryptedContent = await response.arrayBuffer(); return this.cipherEncryptionService.decryptAttachmentContent( cipherDomain, diff --git a/libs/common/src/vault/utils/cipher-view-like-utils.ts b/libs/common/src/vault/utils/cipher-view-like-utils.ts index 1c7a4382a04..5cb4a7a084e 100644 --- a/libs/common/src/vault/utils/cipher-view-like-utils.ts +++ b/libs/common/src/vault/utils/cipher-view-like-utils.ts @@ -80,6 +80,18 @@ export class CipherViewLikeUtils { return cipher.isDeleted; }; + /** @returns `true` when the cipher is not assigned to a collection, `false` otherwise. */ + static isUnassigned = (cipher: CipherViewLike): boolean => { + if (this.isCipherListView(cipher)) { + return ( + cipher.organizationId != null && + (cipher.collectionIds == null || cipher.collectionIds.length === 0) + ); + } + + return cipher.isUnassigned; + }; + /** @returns `true` when the user can assign the cipher to a collection, `false` otherwise. */ static canAssignToCollections = (cipher: CipherViewLike): boolean => { if (this.isCipherListView(cipher)) { @@ -174,13 +186,19 @@ export class CipherViewLikeUtils { targetUri: string, equivalentDomains: Set, defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain, + overrideNeverMatchStrategy?: true, ): boolean => { if (CipherViewLikeUtils.getType(cipher) !== CipherType.Login) { return false; } if (!this.isCipherListView(cipher)) { - return cipher.login.matchesUri(targetUri, equivalentDomains, defaultUriMatch); + return cipher.login.matchesUri( + targetUri, + equivalentDomains, + defaultUriMatch, + overrideNeverMatchStrategy, + ); } const login = this.getLogin(cipher); @@ -198,7 +216,7 @@ export class CipherViewLikeUtils { }); return loginUriViews.some((uriView) => - uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch), + uriView.matchesUri(targetUri, equivalentDomains, defaultUriMatch, overrideNeverMatchStrategy), ); }; diff --git a/libs/importer/src/components/chrome/import-chrome.component.html b/libs/importer/src/components/chrome/import-chrome.component.html new file mode 100644 index 00000000000..284f8cec857 --- /dev/null +++ b/libs/importer/src/components/chrome/import-chrome.component.html @@ -0,0 +1,8 @@ +
+ + {{ "browserProfile" | i18n }} + + + + +
diff --git a/libs/importer/src/components/chrome/import-chrome.component.ts b/libs/importer/src/components/chrome/import-chrome.component.ts new file mode 100644 index 00000000000..035487fea6f --- /dev/null +++ b/libs/importer/src/components/chrome/import-chrome.component.ts @@ -0,0 +1,167 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { CommonModule } from "@angular/common"; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; +import { + AsyncValidatorFn, + ControlContainer, + FormBuilder, + FormGroup, + ReactiveFormsModule, + Validators, +} from "@angular/forms"; +import * as papa from "papaparse"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +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 { + CalloutModule, + CheckboxModule, + FormFieldModule, + IconButtonModule, + SelectModule, + TypographyModule, +} from "@bitwarden/components"; + +import { ImportType } from "../../models"; + +@Component({ + selector: "import-chrome", + templateUrl: "import-chrome.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + CalloutModule, + TypographyModule, + FormFieldModule, + ReactiveFormsModule, + IconButtonModule, + CheckboxModule, + SelectModule, + ], +}) +export class ImportChromeComponent implements OnInit, OnDestroy { + private _parentFormGroup: FormGroup; + protected formGroup = this.formBuilder.group({ + profile: [ + "", + { + nonNullable: true, + validators: [Validators.required], + asyncValidators: [this.validateAndEmitData()], + updateOn: "submit", + }, + ], + }); + + profileList: { id: string; name: string }[] = []; + + @Input() + format: ImportType; + + @Input() + onLoadProfilesFromBrowser: (browser: string) => Promise; + + @Input() + onImportFromBrowser: (browser: string, profile: string) => Promise; + + @Output() csvDataLoaded = new EventEmitter(); + + constructor( + private formBuilder: FormBuilder, + private controlContainer: ControlContainer, + private logService: LogService, + private i18nService: I18nService, + ) {} + + async ngOnInit(): Promise { + this._parentFormGroup = this.controlContainer.control as FormGroup; + this._parentFormGroup.addControl("chromeOptions", this.formGroup); + this.profileList = await this.onLoadProfilesFromBrowser(this.getBrowserName()); + } + + ngOnDestroy(): void { + this._parentFormGroup.removeControl("chromeOptions"); + } + + /** + * Attempts to login to the provided Chrome email and retrieve account contents. + * Will return a validation error if unable to login or fetch. + * Emits account contents to `csvDataLoaded` + */ + validateAndEmitData(): AsyncValidatorFn { + return async () => { + try { + const logins = await this.onImportFromBrowser( + this.getBrowserName(), + this.formGroup.controls.profile.value, + ); + if (logins.length === 0) { + throw "nothing to import"; + } + const chromeLogins: ChromeLogin[] = []; + for (const l of logins) { + if (l.login != null) { + chromeLogins.push(new ChromeLogin(l.login)); + } + } + const csvData = papa.unparse(chromeLogins); + this.csvDataLoaded.emit(csvData); + return null; + } catch (error) { + this.logService.error(`Chromium importer error: ${error}`); + return { + errors: { + message: this.i18nService.t(this.getValidationErrorI18nKey(error)), + }, + }; + } + }; + } + + private getValidationErrorI18nKey(error: any): string { + const message = typeof error === "string" ? error : error?.message; + switch (message) { + default: + return "errorOccurred"; + } + } + + private getBrowserName(): string { + if (this.format === "edgecsv") { + return "Microsoft Edge"; + } else if (this.format === "operacsv") { + return "Opera"; + } else if (this.format === "bravecsv") { + return "Brave"; + } else if (this.format === "vivaldicsv") { + return "Vivaldi"; + } + return "Chrome"; + } +} + +class ChromeLogin { + name: string; + url: string; + username: string; + password: string; + note: string; + + constructor(login: any) { + const url = Utils.getUrl(login?.url); + if (url != null) { + this.name = new URL(url).hostname; + } + if (this.name == null) { + this.name = login.url; + } + this.url = login.url; + this.username = login.username; + this.password = login.password; + this.note = login.note; + } +} diff --git a/libs/importer/src/components/chrome/index.ts b/libs/importer/src/components/chrome/index.ts new file mode 100644 index 00000000000..1365c155038 --- /dev/null +++ b/libs/importer/src/components/chrome/index.ts @@ -0,0 +1 @@ +export { ImportChromeComponent } from "./import-chrome.component"; diff --git a/libs/importer/src/components/import.component.html b/libs/importer/src/components/import.component.html index d0107bb5808..9f1247b52da 100644 --- a/libs/importer/src/components/import.component.html +++ b/libs/importer/src/components/import.component.html @@ -169,27 +169,41 @@ "Export" to save the JSON file. --> - + The process is exactly the same as importing from Google Chrome. - See detailed instructions on our help site at - - https://bitwarden.com/help/import-from-chrome/ + See detailed instructions on our help site at + + https://bitwarden.com/help/import-from-chrome/ +

+ + + {{ "importDirectlyFromBrowser" | i18n }} + + + {{ "importFromCSV" | i18n }} + +
See detailed instructions on our help site at @@ -440,12 +454,20 @@ previously chosen. - -
+ @if (showLastPassOptions) { + + } @else if (showChromiumOptions$ | async) { + + } @else { {{ "selectImportFile" | i18n }}
@@ -473,7 +495,7 @@ formControlName="fileContents" > -
+ } diff --git a/libs/importer/src/components/import.component.ts b/libs/importer/src/components/import.component.ts index 646db8d643e..774392be879 100644 --- a/libs/importer/src/components/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -4,6 +4,7 @@ import { CommonModule } from "@angular/common"; import { AfterViewInit, Component, + DestroyRef, EventEmitter, Inject, Input, @@ -13,17 +14,23 @@ import { Output, ViewChild, } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; import * as JSZip from "jszip"; -import { Observable, Subject, lastValueFrom, combineLatest, firstValueFrom } from "rxjs"; +import { + Observable, + Subject, + lastValueFrom, + combineLatest, + firstValueFrom, + BehaviorSubject, +} from "rxjs"; import { combineLatestWith, filter, map, switchMap, takeUntil } from "rxjs/operators"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { safeProvider, SafeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; -import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { getOrganizationById, OrganizationService, @@ -34,14 +41,10 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ClientType } from "@bitwarden/common/enums"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; @@ -62,49 +65,20 @@ import { ToastService, LinkModule, } from "@bitwarden/components"; -import { KeyService } from "@bitwarden/key-management"; +import { ImporterMetadata, DataLoader, Loader, Instructions } from "../metadata"; import { ImportOption, ImportResult, ImportType } from "../models"; -import { - ImportApiService, - ImportApiServiceAbstraction, - ImportCollectionServiceAbstraction, - ImportService, - ImportServiceAbstraction, -} from "../services"; +import { ImportCollectionServiceAbstraction, ImportServiceAbstraction } from "../services"; +import { ImportChromeComponent } from "./chrome"; import { FilePasswordPromptComponent, ImportErrorDialogComponent, ImportSuccessDialogComponent, } from "./dialog"; +import { ImporterProviders } from "./importer-providers"; import { ImportLastPassComponent } from "./lastpass"; -const safeProviders: SafeProvider[] = [ - safeProvider({ - provide: ImportApiServiceAbstraction, - useClass: ImportApiService, - deps: [ApiService], - }), - safeProvider({ - provide: ImportServiceAbstraction, - useClass: ImportService, - deps: [ - CipherService, - FolderService, - ImportApiServiceAbstraction, - I18nService, - CollectionService, - KeyService, - EncryptService, - PinServiceAbstraction, - AccountService, - SdkService, - RestrictedItemTypesService, - ], - }), -]; - @Component({ selector: "tools-import", templateUrl: "import.component.html", @@ -118,6 +92,7 @@ const safeProviders: SafeProvider[] = [ SelectModule, CalloutModule, ReactiveFormsModule, + ImportChromeComponent, ImportLastPassComponent, RadioButtonModule, CardComponent, @@ -125,7 +100,7 @@ const safeProviders: SafeProvider[] = [ SectionComponent, LinkModule, ], - providers: safeProviders, + providers: ImporterProviders, }) export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { featuredImportOptions: ImportOption[]; @@ -160,6 +135,12 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { }); } + @Input() + onLoadProfilesFromBrowser: (browser: string) => Promise; + + @Input() + onImportFromBrowser: (browser: string, profile: string) => Promise; + protected organization: Organization; protected destroy$ = new Subject(); @@ -184,6 +165,8 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { fileContents: [], file: [], lastPassType: ["direct" as "csv" | "direct"], + // FIXME: once the flag is disabled this should initialize to `Strategy.browser` + chromiumLoader: [Loader.file as DataLoader], }); @ViewChild(BitSubmitDirective) @@ -208,6 +191,26 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { }); } + private importer$ = new BehaviorSubject(undefined); + + /** emits `true` when the chromium instruction block should be visible. */ + protected readonly showChromiumInstructions$ = this.importer$.pipe( + map((importer) => importer?.instructions === Instructions.chromium), + ); + + /** emits `true` when direct browser import is available. */ + // FIXME: use the capabilities list to populate `chromiumLoader` and replace the explicit + // strategy check with a check for multiple loaders + protected readonly browserImporterAvailable$ = this.importer$.pipe( + map((importer) => (importer?.loaders ?? []).includes(Loader.chromium)), + ); + + /** emits `true` when the chromium loader is selected. */ + protected readonly showChromiumOptions$ = + this.formGroup.controls.chromiumLoader.valueChanges.pipe( + map((chromiumLoader) => chromiumLoader === Loader.chromium), + ); + constructor( protected i18nService: I18nService, protected importService: ImportServiceAbstraction, @@ -226,6 +229,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { protected toastService: ToastService, protected accountService: AccountService, private restrictedItemTypesService: RestrictedItemTypesService, + private destroyRef: DestroyRef, ) {} protected get importBlockedByPolicy(): boolean { @@ -246,6 +250,23 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { async ngOnInit() { this.setImportOptions(); + this.importService + .metadata$(this.formGroup.controls.format.valueChanges) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (importer) => { + this.importer$.next(importer); + + // when an importer is defined, the loader needs to be set to a value from + // its list. + const loader = importer.loaders.includes(Loader.chromium) + ? Loader.chromium + : importer.loaders?.[0]; + this.formGroup.controls.chromiumLoader.setValue(loader ?? Loader.file); + }, + error: (err: unknown) => this.logService.error("an error occurred", err), + }); + if (this.organizationId) { await this.handleOrganizationImportInit(); } else { @@ -578,7 +599,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { private async setImportContents(): Promise { const fileEl = document.getElementById("import_input_file") as HTMLInputElement; - const files = fileEl.files; + const files = fileEl?.files; let fileContents = this.formGroup.controls.fileContents.value; if (files != null && files.length > 0) { diff --git a/libs/importer/src/components/importer-providers.ts b/libs/importer/src/components/importer-providers.ts new file mode 100644 index 00000000000..b00bd65211e --- /dev/null +++ b/libs/importer/src/components/importer-providers.ts @@ -0,0 +1,91 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { CollectionService } from "@bitwarden/admin-console/common"; +import { safeProvider, SafeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { KeyServiceLegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/key-service-legacy-encryptor-provider"; +import { LegacyEncryptorProvider } from "@bitwarden/common/tools/cryptography/legacy-encryptor-provider"; +import { ExtensionRegistry } from "@bitwarden/common/tools/extension/extension-registry.abstraction"; +import { buildExtensionRegistry } from "@bitwarden/common/tools/extension/factory"; +import { + createSystemServiceProvider, + SystemServiceProvider, +} from "@bitwarden/common/tools/providers"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { KeyService } from "@bitwarden/key-management"; +import { StateProvider } from "@bitwarden/state"; +import { SafeInjectionToken } from "@bitwarden/ui-common"; + +import { + ImportApiService, + ImportApiServiceAbstraction, + ImportService, + ImportServiceAbstraction, +} from "../services"; + +// FIXME: unify with `SYSTEM_SERVICE_PROVIDER` when migrating it from the generator component module +// to a general module. +const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken("SystemServices"); + +/** Import service factories */ +export const ImporterProviders: SafeProvider[] = [ + safeProvider({ + provide: ImportApiServiceAbstraction, + useClass: ImportApiService, + deps: [ApiService], + }), + safeProvider({ + provide: LegacyEncryptorProvider, + useClass: KeyServiceLegacyEncryptorProvider, + deps: [EncryptService, KeyService], + }), + safeProvider({ + provide: ExtensionRegistry, + useFactory: () => { + return buildExtensionRegistry(); + }, + deps: [], + }), + safeProvider({ + provide: SYSTEM_SERVICE_PROVIDER, + useFactory: createSystemServiceProvider, + deps: [ + LegacyEncryptorProvider, + StateProvider, + PolicyService, + ExtensionRegistry, + LogService, + PlatformUtilsService, + ConfigService, + ], + }), + safeProvider({ + provide: ImportServiceAbstraction, + useClass: ImportService, + deps: [ + CipherService, + FolderService, + ImportApiServiceAbstraction, + I18nService, + CollectionService, + KeyService, + EncryptService, + PinServiceAbstraction, + AccountService, + RestrictedItemTypesService, + SYSTEM_SERVICE_PROVIDER, + ], + }), +]; diff --git a/libs/importer/src/importers/chrome-csv-importer.ts b/libs/importer/src/importers/chrome-csv-importer.ts index 445f0ad57ae..c7a72c126b0 100644 --- a/libs/importer/src/importers/chrome-csv-importer.ts +++ b/libs/importer/src/importers/chrome-csv-importer.ts @@ -24,6 +24,7 @@ export class ChromeCsvImporter extends BaseImporter implements Importer { cipher.login.username = this.getValueOrDefault(value.username); cipher.login.password = this.getValueOrDefault(value.password); cipher.login.uris = this.makeUriArray(value.url); + cipher.notes = this.getValueOrDefault(value.note); this.cleanupCipher(cipher); result.ciphers.push(cipher); }); diff --git a/libs/importer/src/metadata/availability.ts b/libs/importer/src/metadata/availability.ts new file mode 100644 index 00000000000..0ac7269496a --- /dev/null +++ b/libs/importer/src/metadata/availability.ts @@ -0,0 +1,15 @@ +import { ClientType } from "@bitwarden/client-type"; +import { deepFreeze } from "@bitwarden/common/tools/util"; + +import { Loader } from "./data"; +import { DataLoader } from "./types"; + +/** Describes which loaders are supported on each client */ +export const LoaderAvailability: Record = deepFreeze({ + [Loader.chromium]: [ClientType.Desktop], + [Loader.download]: [ClientType.Browser], + [Loader.file]: [ClientType.Browser, ClientType.Desktop, ClientType.Web, ClientType.Cli], + + // FIXME: enable IPC importer on `ClientType.Desktop` once it's ready + [Loader.ipc]: [], +}); diff --git a/libs/importer/src/metadata/data.ts b/libs/importer/src/metadata/data.ts new file mode 100644 index 00000000000..82edd5cdc2d --- /dev/null +++ b/libs/importer/src/metadata/data.ts @@ -0,0 +1,27 @@ +/** Mechanisms that load data into the importer. */ +export const Loader = Object.freeze({ + /** Data loaded from a file provided by the user/ */ + file: "file", + + /** Data loaded directly from the chromium browser's data store */ + chromium: "chromium", + + /** Data provided through an importer ipc channel (e.g. Bitwarden bridge) */ + ipc: "ipc", + + /** Data provided through direct file download (e.g. a LastPass export) */ + download: "download", +}); + +/** Re-branded products often leave their exporters unaltered; when that occurs, + * `Instructions` lets us group them together. + * + * @remarks Instructions values must be mutually exclusive from Loader's values. + */ +export const Instructions = Object.freeze({ + /** the instructions are unique to the import type */ + unique: "unique", + + /** shared chromium instructions */ + chromium: "chromium", +}); diff --git a/libs/importer/src/metadata/importers.ts b/libs/importer/src/metadata/importers.ts new file mode 100644 index 00000000000..efd5eafe7d5 --- /dev/null +++ b/libs/importer/src/metadata/importers.ts @@ -0,0 +1,27 @@ +import { deepFreeze } from "@bitwarden/common/tools/util"; + +import { ImportType } from "../models"; + +import { Loader, Instructions } from "./data"; +import { ImporterMetadata } from "./types"; + +// FIXME: load this data from rust code +const importers = [ + // chromecsv import depends upon operating system, so ironically it doesn't support chromium + { id: "chromecsv", loaders: [Loader.file], instructions: Instructions.chromium }, + { id: "operacsv", loaders: [Loader.file, Loader.chromium], instructions: Instructions.chromium }, + { + id: "vivaldicsv", + loaders: [Loader.file, Loader.chromium], + instructions: Instructions.chromium, + }, + { id: "bravecsv", loaders: [Loader.file, Loader.chromium], instructions: Instructions.chromium }, + { id: "edgecsv", loaders: [Loader.file, Loader.chromium], instructions: Instructions.chromium }, + + // FIXME: add other formats and remove `Partial` from export +] as const; + +/** Describes which loaders are available for each import type */ +export const Importers: Partial> = deepFreeze( + Object.fromEntries(importers.map((i) => [i.id, i])), +); diff --git a/libs/importer/src/metadata/index.ts b/libs/importer/src/metadata/index.ts new file mode 100644 index 00000000000..17c009ae68e --- /dev/null +++ b/libs/importer/src/metadata/index.ts @@ -0,0 +1,4 @@ +export * from "./availability"; +export * from "./data"; +export * from "./types"; +export * from "./importers"; diff --git a/libs/importer/src/metadata/types.ts b/libs/importer/src/metadata/types.ts new file mode 100644 index 00000000000..09b3fe97fd5 --- /dev/null +++ b/libs/importer/src/metadata/types.ts @@ -0,0 +1,20 @@ +import { ImportType } from "../models"; + +import { Instructions, Loader } from "./data"; + +/** Mechanisms that load data into the importer. */ +export type DataLoader = (typeof Loader)[keyof typeof Loader]; + +export type InstructionLink = (typeof Instructions)[keyof typeof Instructions]; + +/** Mechanisms that load data into the importer. */ +export type ImporterMetadata = { + /** Identifies the importer */ + type: ImportType; + + /** Identifies the instructions for the importer; this defaults to `unique`. */ + instructions?: InstructionLink; + + /** Describes the strategies used to obtain imported data */ + loaders: DataLoader[]; +}; diff --git a/libs/importer/src/models/import-options.ts b/libs/importer/src/models/import-options.ts index 205dbaf0198..22a4f63b248 100644 --- a/libs/importer/src/models/import-options.ts +++ b/libs/importer/src/models/import-options.ts @@ -6,7 +6,7 @@ export interface ImportOption { export const featuredImportOptions = [ { id: "bitwardenjson", name: "Bitwarden (json)" }, { id: "bitwardencsv", name: "Bitwarden (csv)" }, - { id: "chromecsv", name: "Chrome (csv)" }, + { id: "chromecsv", name: "Chrome" }, { id: "dashlanecsv", name: "Dashlane (csv)" }, { id: "firefoxcsv", name: "Firefox (csv)" }, { id: "keepass2xml", name: "KeePass 2 (xml)" }, @@ -46,9 +46,10 @@ export const regularImportOptions = [ { id: "ascendocsv", name: "Ascendo DataVault (csv)" }, { id: "meldiumcsv", name: "Meldium (csv)" }, { id: "passkeepcsv", name: "PassKeep (csv)" }, - { id: "edgecsv", name: "Edge (csv)" }, - { id: "operacsv", name: "Opera (csv)" }, - { id: "vivaldicsv", name: "Vivaldi (csv)" }, + { id: "edgecsv", name: "Edge" }, + { id: "operacsv", name: "Opera" }, + { id: "vivaldicsv", name: "Vivaldi" }, + { id: "bravecsv", name: "Brave" }, { id: "gnomejson", name: "GNOME Passwords and Keys/Seahorse (json)" }, { id: "blurcsv", name: "Blur (csv)" }, { id: "passwordagentcsv", name: "Password Agent (csv)" }, diff --git a/libs/importer/src/services/import.service.abstraction.ts b/libs/importer/src/services/import.service.abstraction.ts index d869dc71cc7..ee0d1ed33ab 100644 --- a/libs/importer/src/services/import.service.abstraction.ts +++ b/libs/importer/src/services/import.service.abstraction.ts @@ -1,11 +1,14 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { Observable } from "rxjs"; + // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionView } from "@bitwarden/admin-console/common"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { Importer } from "../importers/importer"; +import { ImporterMetadata } from "../metadata"; import { ImportOption, ImportType } from "../models/import-options"; import { ImportResult } from "../models/import-result"; @@ -13,6 +16,10 @@ export abstract class ImportServiceAbstraction { featuredImportOptions: readonly ImportOption[]; regularImportOptions: readonly ImportOption[]; getImportOptions: () => ImportOption[]; + + /** describes the features supported by a format */ + metadata$: (type$: Observable) => Observable; + import: ( importer: Importer, fileContents: string, diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index ad6e6ebf016..c3d555af936 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -1,14 +1,20 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, Subject, firstValueFrom } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { ClientType } from "@bitwarden/client-type"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { MockSdkService } from "@bitwarden/common/platform/spec/mock-sdk.service"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -19,6 +25,8 @@ import { KeyService } from "@bitwarden/key-management"; import { BitwardenPasswordProtectedImporter } from "../importers/bitwarden/bitwarden-password-protected-importer"; import { Importer } from "../importers/importer"; +import { ImporterMetadata, Instructions, Loader } from "../metadata"; +import { ImportType } from "../models"; import { ImportResult } from "../models/import-result"; import { ImportApiServiceAbstraction } from "./import-api.service.abstraction"; @@ -35,8 +43,8 @@ describe("ImportService", () => { let encryptService: MockProxy; let pinService: MockProxy; let accountService: MockProxy; - let sdkService: MockSdkService; let restrictedItemTypesService: MockProxy; + let systemServiceProvider: MockProxy; beforeEach(() => { cipherService = mock(); @@ -47,9 +55,20 @@ describe("ImportService", () => { keyService = mock(); encryptService = mock(); pinService = mock(); - sdkService = new MockSdkService(); restrictedItemTypesService = mock(); + const configService = mock(); + configService.getFeatureFlag$.mockReturnValue(new BehaviorSubject(false)); + + const environment = mock(); + environment.getClientType.mockReturnValue(ClientType.Desktop); + + systemServiceProvider = mock({ + configService, + environment, + log: jest.fn().mockReturnValue({ debug: jest.fn() }), + }); + importService = new ImportService( cipherService, folderService, @@ -60,8 +79,8 @@ describe("ImportService", () => { encryptService, pinService, accountService, - sdkService, restrictedItemTypesService, + systemServiceProvider, ); }); @@ -249,6 +268,170 @@ describe("ImportService", () => { expect(importResult.folderRelationships[1]).toEqual([0, 1]); }); }); + + describe("metadata$", () => { + let featureFlagSubject: BehaviorSubject; + let typeSubject: Subject; + let mockLogger: { debug: jest.Mock }; + + beforeEach(() => { + featureFlagSubject = new BehaviorSubject(false); + typeSubject = new Subject(); + mockLogger = { debug: jest.fn() }; + + const configService = mock(); + configService.getFeatureFlag$.mockReturnValue(featureFlagSubject); + + const environment = mock(); + environment.getClientType.mockReturnValue(ClientType.Desktop); + + systemServiceProvider = mock({ + configService, + environment, + log: jest.fn().mockReturnValue(mockLogger), + }); + + // Recreate the service with the updated mocks for logging tests + importService = new ImportService( + cipherService, + folderService, + importApiService, + i18nService, + collectionService, + keyService, + encryptService, + pinService, + accountService, + restrictedItemTypesService, + systemServiceProvider, + ); + }); + + afterEach(() => { + featureFlagSubject.complete(); + typeSubject.complete(); + }); + + it("should emit metadata when type$ emits", async () => { + const testType: ImportType = "chromecsv"; + + const metadataPromise = firstValueFrom(importService.metadata$(typeSubject)); + typeSubject.next(testType); + + const result = await metadataPromise; + + expect(result).toEqual({ + type: testType, + loaders: expect.any(Array), + instructions: Instructions.chromium, + }); + expect(result.type).toBe(testType); + }); + + it("should include all loaders when chromium feature flag is enabled", async () => { + const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders + featureFlagSubject.next(true); + + const metadataPromise = firstValueFrom(importService.metadata$(typeSubject)); + typeSubject.next(testType); + + const result = await metadataPromise; + + expect(result.loaders).toContain(Loader.chromium); + expect(result.loaders).toContain(Loader.file); + }); + + it("should exclude chromium loader when feature flag is disabled", async () => { + const testType: ImportType = "bravecsv"; // bravecsv supports both file and chromium loaders + featureFlagSubject.next(false); + + const metadataPromise = firstValueFrom(importService.metadata$(typeSubject)); + typeSubject.next(testType); + + const result = await metadataPromise; + + expect(result.loaders).not.toContain(Loader.chromium); + expect(result.loaders).toContain(Loader.file); + }); + + it("should update when type$ changes", async () => { + const emissions: ImporterMetadata[] = []; + const subscription = importService.metadata$(typeSubject).subscribe((metadata) => { + emissions.push(metadata); + }); + + typeSubject.next("chromecsv"); + typeSubject.next("bravecsv"); + + // Wait for emissions + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emissions).toHaveLength(2); + expect(emissions[0].type).toBe("chromecsv"); + expect(emissions[1].type).toBe("bravecsv"); + + subscription.unsubscribe(); + }); + + it("should update when feature flag changes", async () => { + const testType: ImportType = "bravecsv"; // Use bravecsv which supports chromium loader + const emissions: ImporterMetadata[] = []; + + const subscription = importService.metadata$(typeSubject).subscribe((metadata) => { + emissions.push(metadata); + }); + + typeSubject.next(testType); + featureFlagSubject.next(true); + + // Wait for emissions + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emissions).toHaveLength(2); + expect(emissions[0].loaders).not.toContain(Loader.chromium); + expect(emissions[1].loaders).toContain(Loader.chromium); + + subscription.unsubscribe(); + }); + + it("should update when both type$ and feature flag change", async () => { + const emissions: ImporterMetadata[] = []; + + const subscription = importService.metadata$(typeSubject).subscribe((metadata) => { + emissions.push(metadata); + }); + + // Initial emission + typeSubject.next("chromecsv"); + + // Change both at the same time + featureFlagSubject.next(true); + typeSubject.next("bravecsv"); + + // Wait for emissions + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(emissions.length).toBeGreaterThanOrEqual(2); + const lastEmission = emissions[emissions.length - 1]; + expect(lastEmission.type).toBe("bravecsv"); + + subscription.unsubscribe(); + }); + + it("should log debug information with correct data", async () => { + const testType: ImportType = "chromecsv"; + + const metadataPromise = firstValueFrom(importService.metadata$(typeSubject)); + typeSubject.next(testType); + + await metadataPromise; + + expect(mockLogger.debug).toHaveBeenCalledWith( + { importType: testType, capabilities: expect.any(Object) }, + "capabilities updated", + ); + }); + }); }); function createCipher(options: Partial = {}) { diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 133607251c3..e868a5ac516 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom, map } from "rxjs"; +import { combineLatest, firstValueFrom, map, Observable } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -10,6 +10,7 @@ import { CollectionView, } from "@bitwarden/admin-console/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { ImportCiphersRequest } from "@bitwarden/common/models/request/import-ciphers.request"; @@ -17,8 +18,9 @@ import { ImportOrganizationCiphersRequest } from "@bitwarden/common/models/reque import { KvpRequest } from "@bitwarden/common/models/request/kvp.request"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SemanticLogger } from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; import { OrganizationId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -95,6 +97,7 @@ import { PasswordDepot17XmlImporter, } from "../importers"; import { Importer } from "../importers/importer"; +import { ImporterMetadata, Importers, Loader } from "../metadata"; import { featuredImportOptions, ImportOption, @@ -104,12 +107,15 @@ import { import { ImportResult } from "../models/import-result"; import { ImportApiServiceAbstraction } from "../services/import-api.service.abstraction"; import { ImportServiceAbstraction } from "../services/import.service.abstraction"; +import { availableLoaders as availableLoaders } from "../util"; export class ImportService implements ImportServiceAbstraction { featuredImportOptions = featuredImportOptions as readonly ImportOption[]; regularImportOptions = regularImportOptions as readonly ImportOption[]; + private logger: SemanticLogger; + constructor( private cipherService: CipherService, private folderService: FolderService, @@ -120,14 +126,42 @@ export class ImportService implements ImportServiceAbstraction { private encryptService: EncryptService, private pinService: PinServiceAbstraction, private accountService: AccountService, - private sdkService: SdkService, private restrictedItemTypesService: RestrictedItemTypesService, - ) {} + private system: SystemServiceProvider, + ) { + this.logger = system.log({ type: "ImportService" }); + } getImportOptions(): ImportOption[] { return this.featuredImportOptions.concat(this.regularImportOptions); } + metadata$(type$: Observable): Observable { + const browserEnabled$ = this.system.configService.getFeatureFlag$( + FeatureFlag.UseChromiumImporter, + ); + const client = this.system.environment.getClientType(); + const capabilities$ = combineLatest([type$, browserEnabled$]).pipe( + map(([type, enabled]) => { + let loaders = availableLoaders(type, client); + if (!enabled) { + loaders = loaders?.filter((loader) => loader !== Loader.chromium); + } + + const capabilities: ImporterMetadata = { type, loaders }; + if (type in Importers) { + capabilities.instructions = Importers[type].instructions; + } + + this.logger.debug({ importType: type, capabilities }, "capabilities updated"); + + return capabilities; + }), + ); + + return capabilities$; + } + async import( importer: Importer, fileContents: string, @@ -260,6 +294,7 @@ export class ImportService implements ImportServiceAbstraction { case "chromecsv": case "operacsv": case "vivaldicsv": + case "bravecsv": return new ChromeCsvImporter(); case "firefoxcsv": return new FirefoxCsvImporter(); diff --git a/libs/importer/src/util.spec.ts b/libs/importer/src/util.spec.ts new file mode 100644 index 00000000000..5a68e3cea12 --- /dev/null +++ b/libs/importer/src/util.spec.ts @@ -0,0 +1,60 @@ +import { ClientType } from "@bitwarden/client-type"; + +import { Loader } from "./metadata"; +import { availableLoaders } from "./util"; + +describe("availableLoaders", () => { + describe("given valid import types", () => { + it("returns available loaders when client supports all loaders", () => { + const result = availableLoaders("operacsv", ClientType.Desktop); + + expect(result).toEqual([Loader.file, Loader.chromium]); + }); + + it("returns filtered loaders when client supports some loaders", () => { + const result = availableLoaders("operacsv", ClientType.Browser); + + expect(result).toEqual([Loader.file]); + }); + + it("returns single loader for import types with one loader", () => { + const result = availableLoaders("chromecsv", ClientType.Desktop); + + expect(result).toEqual([Loader.file]); + }); + + it("returns all supported loaders for multi-loader import types", () => { + const result = availableLoaders("bravecsv", ClientType.Desktop); + + expect(result).toEqual([Loader.file, Loader.chromium]); + }); + }); + + describe("given unknown import types", () => { + it("returns undefined when import type is not found in metadata", () => { + const result = availableLoaders("nonexistent" as any, ClientType.Desktop); + + expect(result).toBeUndefined(); + }); + }); + + describe("given different client types", () => { + it("returns appropriate loaders for Browser client", () => { + const result = availableLoaders("operacsv", ClientType.Browser); + + expect(result).toEqual([Loader.file]); + }); + + it("returns appropriate loaders for Web client", () => { + const result = availableLoaders("chromecsv", ClientType.Web); + + expect(result).toEqual([Loader.file]); + }); + + it("returns appropriate loaders for CLI client", () => { + const result = availableLoaders("vivaldicsv", ClientType.Cli); + + expect(result).toEqual([Loader.file]); + }); + }); +}); diff --git a/libs/importer/src/util.ts b/libs/importer/src/util.ts new file mode 100644 index 00000000000..0a76b7e753b --- /dev/null +++ b/libs/importer/src/util.ts @@ -0,0 +1,19 @@ +import { ClientType } from "@bitwarden/client-type"; + +import { LoaderAvailability, Importers } from "./metadata"; +import { ImportType } from "./models"; + +/** Lookup the loaders supported by a specific client. + * WARNING: this method does not supply metadata for every import type. + * @returns `undefined` when metadata is not defined for the type, or + * an array identifying the supported clients. + */ +export function availableLoaders(type: ImportType, client: ClientType) { + if (!(type in Importers)) { + return undefined; + } + + const capabilities = Importers[type]?.loaders ?? []; + const available = capabilities.filter((loader) => LoaderAvailability[loader].includes(client)); + return available; +} diff --git a/libs/key-management-ui/src/index.ts b/libs/key-management-ui/src/index.ts index b330e390d36..6754722440a 100644 --- a/libs/key-management-ui/src/index.ts +++ b/libs/key-management-ui/src/index.ts @@ -8,3 +8,4 @@ export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component"; export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component"; export { RemovePasswordComponent } from "./key-connector/remove-password.component"; +export { ConfirmKeyConnectorDomainComponent } from "./key-connector/confirm-key-connector-domain.component"; diff --git a/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.html b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.html new file mode 100644 index 00000000000..6cf151d4604 --- /dev/null +++ b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.html @@ -0,0 +1,24 @@ +@if (loading) { +
+ + {{ "loading" | i18n }} +
+} @else { +
+

{{ "keyConnectorDomain" | i18n }}:

+

{{ keyConnectorUrl }}

+
+ +
+ + +
+} diff --git a/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.spec.ts b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.spec.ts new file mode 100644 index 00000000000..b53b0a196f5 --- /dev/null +++ b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.spec.ts @@ -0,0 +1,116 @@ +import { Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { KeyConnectorDomainConfirmation } from "@bitwarden/common/key-management/key-connector/models/key-connector-domain-confirmation"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { mockAccountServiceWith } from "@bitwarden/common/spec"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { ConfirmKeyConnectorDomainComponent } from "./confirm-key-connector-domain.component"; + +describe("ConfirmKeyConnectorDomainComponent", () => { + let component: ConfirmKeyConnectorDomainComponent; + + const userId = "test-user-id" as UserId; + const confirmation: KeyConnectorDomainConfirmation = { + keyConnectorUrl: "https://key-connector-url.com", + }; + + const mockRouter = mock(); + const mockSyncService = mock(); + const mockKeyConnectorService = mock(); + const mockLogService = mock(); + const mockMessagingService = mock(); + let mockAccountService = mockAccountServiceWith(userId); + const onBeforeNavigation = jest.fn(); + + beforeEach(async () => { + jest.clearAllMocks(); + + mockAccountService = mockAccountServiceWith(userId); + + component = new ConfirmKeyConnectorDomainComponent( + mockRouter, + mockLogService, + mockKeyConnectorService, + mockMessagingService, + mockSyncService, + mockAccountService, + ); + + jest.spyOn(component, "onBeforeNavigation").mockImplementation(onBeforeNavigation); + + // Mock key connector service to return data from state + mockKeyConnectorService.requiresDomainConfirmation$.mockReturnValue(of(confirmation)); + }); + + describe("ngOnInit", () => { + it("should logout when no active account", async () => { + mockAccountService.activeAccount$ = of(null); + + await component.ngOnInit(); + + expect(mockMessagingService.send).toHaveBeenCalledWith("logout"); + expect(component.loading).toEqual(true); + }); + + it("should logout when confirmation is null", async () => { + mockKeyConnectorService.requiresDomainConfirmation$.mockReturnValue(of(null)); + + await component.ngOnInit(); + + expect(mockMessagingService.send).toHaveBeenCalledWith("logout"); + expect(component.loading).toEqual(true); + }); + + it("should set component properties correctly", async () => { + await component.ngOnInit(); + + expect(component.userId).toEqual(userId); + expect(component.keyConnectorUrl).toEqual(confirmation.keyConnectorUrl); + expect(component.loading).toEqual(false); + }); + }); + + describe("confirm", () => { + it("should call keyConnectorService.convertNewSsoUserToKeyConnector with full sync and navigation to home page", async () => { + await component.ngOnInit(); + + await component.confirm(); + + expect(mockKeyConnectorService.convertNewSsoUserToKeyConnector).toHaveBeenCalledWith(userId); + expect(mockSyncService.fullSync).toHaveBeenCalledWith(true); + expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]); + expect(mockMessagingService.send).toHaveBeenCalledWith("loggedIn"); + expect(onBeforeNavigation).toHaveBeenCalled(); + + expect( + mockKeyConnectorService.convertNewSsoUserToKeyConnector.mock.invocationCallOrder[0], + ).toBeLessThan(mockSyncService.fullSync.mock.invocationCallOrder[0]); + expect(mockSyncService.fullSync.mock.invocationCallOrder[0]).toBeLessThan( + mockMessagingService.send.mock.invocationCallOrder[0], + ); + expect(mockMessagingService.send.mock.invocationCallOrder[0]).toBeLessThan( + onBeforeNavigation.mock.invocationCallOrder[0], + ); + expect(onBeforeNavigation.mock.invocationCallOrder[0]).toBeLessThan( + mockRouter.navigate.mock.invocationCallOrder[0], + ); + }); + }); + + describe("cancel", () => { + it("should logout", async () => { + await component.ngOnInit(); + + await component.cancel(); + + expect(mockMessagingService.send).toHaveBeenCalledWith("logout"); + expect(mockKeyConnectorService.convertNewSsoUserToKeyConnector).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts new file mode 100644 index 00000000000..586c1cc113a --- /dev/null +++ b/libs/key-management-ui/src/key-connector/confirm-key-connector-domain.component.ts @@ -0,0 +1,76 @@ +import { CommonModule } from "@angular/common"; +import { Component, Input, OnInit } from "@angular/core"; +import { Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { UserId } from "@bitwarden/common/types/guid"; +import { BitActionDirective, ButtonModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +@Component({ + selector: "confirm-key-connector-domain", + templateUrl: "confirm-key-connector-domain.component.html", + standalone: true, + imports: [CommonModule, ButtonModule, I18nPipe, BitActionDirective], +}) +export class ConfirmKeyConnectorDomainComponent implements OnInit { + loading = true; + keyConnectorUrl!: string; + userId!: UserId; + + @Input() onBeforeNavigation: () => Promise = async () => {}; + + constructor( + private router: Router, + private logService: LogService, + private keyConnectorService: KeyConnectorService, + private messagingService: MessagingService, + private syncService: SyncService, + private accountService: AccountService, + ) {} + + async ngOnInit() { + try { + this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + } catch { + this.logService.info("[confirm-key-connector-domain] no active account"); + this.messagingService.send("logout"); + return; + } + + const confirmation = await firstValueFrom( + this.keyConnectorService.requiresDomainConfirmation$(this.userId), + ); + if (confirmation == null) { + this.logService.info("[confirm-key-connector-domain] missing required parameters"); + this.messagingService.send("logout"); + return; + } + + this.keyConnectorUrl = confirmation.keyConnectorUrl; + + this.loading = false; + } + + confirm = async () => { + await this.keyConnectorService.convertNewSsoUserToKeyConnector(this.userId); + + await this.syncService.fullSync(true); + + this.messagingService.send("loggedIn"); + + await this.onBeforeNavigation(); + + await this.router.navigate(["/"]); + }; + + cancel = async () => { + this.messagingService.send("logout"); + }; +} diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index 1685938de3d..0f9618cbab9 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -1,6 +1,5 @@ import { Observable } from "rxjs"; -import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data"; import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response"; import { ProfileProviderOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-provider-organization.response"; import { ProfileProviderResponse } from "@bitwarden/common/admin-console/models/response/profile-provider.response"; @@ -406,9 +405,7 @@ export abstract class KeyService { * @deprecated Temporary function to allow the SDK to be initialized after the login process, it * will be removed when auth has been migrated to the SDK. */ - abstract encryptedOrgKeys$( - userId: UserId, - ): Observable | null>; + abstract encryptedOrgKeys$(userId: UserId): Observable>; /** * Gets an observable stream of the users public key. If the user is does not have diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index ed0b844a2a4..53f7c6ed158 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -907,10 +907,57 @@ export class DefaultKeyService implements KeyServiceAbstraction { return this.cipherDecryptionKeys$(userId).pipe(map((keys) => keys?.orgKeys ?? null)); } - encryptedOrgKeys$( - userId: UserId, - ): Observable | null> { - return this.stateProvider.getUser(userId, USER_ENCRYPTED_ORGANIZATION_KEYS).state$; + encryptedOrgKeys$(userId: UserId): Observable> { + return this.userPrivateKey$(userId)?.pipe( + switchMap((userPrivateKey) => { + if (userPrivateKey == null) { + // We can't do any org based decryption + return of({}); + } + + return combineLatest([ + this.stateProvider.getUser(userId, USER_ENCRYPTED_ORGANIZATION_KEYS).state$, + this.providerKeysHelper$(userId, userPrivateKey), + ]).pipe( + switchMap(async ([encryptedOrgKeys, providerKeys]) => { + const userPubKey = await this.derivePublicKey(userPrivateKey); + + const result: Record = {}; + encryptedOrgKeys = encryptedOrgKeys ?? {}; + for (const orgId of Object.keys(encryptedOrgKeys) as OrganizationId[]) { + if (result[orgId] != null) { + continue; + } + const encrypted = BaseEncryptedOrganizationKey.fromData(encryptedOrgKeys[orgId]); + if (encrypted == null) { + continue; + } + + let orgKey: EncString; + + // Because the SDK only supports user encrypted org keys, we need to re-encrypt + // any provider encrypted org keys with the user's public key. This should be removed + // once the SDK has support for provider keys. + if (BaseEncryptedOrganizationKey.isProviderEncrypted(encrypted)) { + if (providerKeys == null) { + continue; + } + orgKey = await this.encryptService.encapsulateKeyUnsigned( + await encrypted.decrypt(this.encryptService, providerKeys!), + userPubKey!, + ); + } else { + orgKey = encrypted.encryptedOrganizationKey; + } + + result[orgId] = orgKey; + } + + return result; + }), + ); + }), + ); } cipherDecryptionKeys$(userId: UserId): Observable { diff --git a/libs/pricing/README.md b/libs/pricing/README.md new file mode 100644 index 00000000000..600dd64f713 --- /dev/null +++ b/libs/pricing/README.md @@ -0,0 +1,5 @@ +# pricing + +Owned by: billing + +Components and services that facilitate the retrieval and display of Bitwarden's pricing. diff --git a/libs/pricing/jest.config.js b/libs/pricing/jest.config.js new file mode 100644 index 00000000000..2aa2bfa8287 --- /dev/null +++ b/libs/pricing/jest.config.js @@ -0,0 +1,16 @@ +const { pathsToModuleNameMapper } = require("ts-jest"); + +const { compilerOptions } = require("../../tsconfig.base"); + +const sharedConfig = require("../../libs/shared/jest.config.angular"); + +/** @type {import('jest').Config} */ +module.exports = { + ...sharedConfig, + displayName: "libs/pricing tests", + setupFilesAfterEnv: ["/test.setup.ts"], + coverageDirectory: "../../coverage/libs/pricing", + moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { + prefix: "/../../", + }), +}; diff --git a/libs/pricing/package.json b/libs/pricing/package.json new file mode 100644 index 00000000000..9d5ec85c1bc --- /dev/null +++ b/libs/pricing/package.json @@ -0,0 +1,21 @@ +{ + "name": "@bitwarden/pricing", + "version": "0.0.0", + "description": "Components and services that facilitate the retrieval and display of Bitwarden's pricing.", + "keywords": [ + "bitwarden" + ], + "author": "Bitwarden Inc.", + "homepage": "https://bitwarden.com", + "repository": { + "type": "git", + "url": "https://github.com/bitwarden/clients" + }, + "license": "GPL-3.0", + "scripts": { + "clean": "rimraf dist", + "build": "npm run clean && tsc", + "build:watch": "npm run clean && tsc -watch" + }, + "private": true +} diff --git a/libs/pricing/project.json b/libs/pricing/project.json new file mode 100644 index 00000000000..7e6e154bceb --- /dev/null +++ b/libs/pricing/project.json @@ -0,0 +1,33 @@ +{ + "name": "pricing", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/pricing/src", + "projectType": "library", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/pricing", + "main": "libs/pricing/src/index.ts", + "tsConfig": "libs/pricing/tsconfig.lib.json", + "assets": ["libs/pricing/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/pricing/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/pricing/jest.config.js" + } + } + } +} diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.html b/libs/pricing/src/components/pricing-card/pricing-card.component.html new file mode 100644 index 00000000000..d0ac4fc519f --- /dev/null +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.html @@ -0,0 +1,85 @@ +
+ +
+ + + + @if (activeBadge(); as activeBadgeValue) { + + {{ activeBadgeValue.text }} + + } +
+ + +
+

+ {{ tagline() }} +

+
+ + + @if (price(); as priceValue) { +
+
+ ${{ priceValue.amount }} + + / {{ priceValue.cadence }} + @if (priceValue.showPerUser) { + per user + } + +
+
+ } + + +
+ @if (button(); as buttonConfig) { + + } +
+ + +
+ @if (features(); as featureList) { + @if (featureList.length > 0) { +
    + @for (feature of featureList; track feature) { +
  • + + {{ + feature + }} +
  • + } +
+ } + } +
+
diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.mdx b/libs/pricing/src/components/pricing-card/pricing-card.component.mdx new file mode 100644 index 00000000000..355ca71eb80 --- /dev/null +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.mdx @@ -0,0 +1,228 @@ +import { Meta, Story, Canvas } from "@storybook/addon-docs"; +import * as PricingCardStories from "./pricing-card.component.stories"; + + + +# Pricing Card + +A reusable UI component for displaying pricing plans with consistent styling and behavior across +Bitwarden applications. + + + +## Usage + +The pricing card component is designed to be used in billing and subscription interfaces to display +different pricing tiers and plans. + +```ts +import { PricingCardComponent } from "@bitwarden/pricing"; +``` + +```html + +

Premium Plan

+
+``` + +## API + +### Inputs + +| Input | Type | Description | +| ------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tagline` | `string` | **Required.** Descriptive text below title (max 2 lines) | +| `price` | `{ amount: number; cadence: "monthly" \| "annually"; showPerUser?: boolean }` | **Optional.** Price information. If omitted, no price is shown | +| `button` | `{ type: ButtonType; text: string; disabled?: boolean; icon?: { type: string; position: "before" \| "after" } }` | **Optional.** Button configuration with optional icon. If omitted, no button is shown. Icon uses `bwi-*` classes, position defaults to "after" | +| `features` | `string[]` | **Optional.** List of features with checkmarks | +| `activeBadge` | `{ text: string; variant?: BadgeVariant }` | **Optional.** Active plan badge using proper Badge component, positioned on the same line as title, aligned to the right. If omitted, no badge is shown | + +### Content Slots + +| Slot | Description | +| -------------- | ----------------------------------------------------------------------------------------------------------------- | +| `slot="title"` | **Required.** HTML element with `slot="title"` attribute for the title with appropriate heading level and styling | + +### Events + +| Event | Type | Description | +| ------------- | ------ | ----------------------------------------- | +| `buttonClick` | `void` | Emitted when the action button is clicked | + +## Flexibility + +The title slot allows complete control over the heading element and styling: + +```html + +

Main Plan

+

Sub Plan

+ + +

Featured Plan

+``` + +| Output | Type | Description | +| ------------- | ------ | --------------------------------------- | +| `buttonClick` | `void` | Emitted when the plan button is clicked | + +## Design + +The component follows the Bitwarden design system with: + +- **Fixed width**: 449px for consistent layout +- **Border & Elevation**: secondary-100 border with shadow-sm elevation +- **Border radius**: 24px (tw-rounded-3xl) for modern appearance +- **Spacing**: 32px padding (tw-p-8) around content +- **Modern Angular**: Uses `@if`, `@for`, and `@switch` control flow with signal inputs +- **Signal inputs**: Type-safe inputs using Angular's signal-based input API +- **Official buttons**: Uses `bitButton` directive from Component Library +- **Typography**: Uses `bitTypography` helper and custom 30px price styling +- **Icons**: Uses `bwi-check` icon with primary-600 styling from the legacy icon library +- **Layout**: Flexbox column layout with `tw-h-full` for equal height alignment in grid layouts +- **Accessibility**: Configurable heading levels and semantic structure + +## Examples + +### Basic Plan (No Price) + +For free or contact-based plans, omit the `price` input: + + + +```html + + +``` + +### Business Plan with Per User Pricing + +Show business plans with "per user" text: + + + +```html + + +``` + +### Annual Pricing + +Show annual pricing with different cadence: + + + +```html + + +``` + +### Configurable Heading Levels + +For accessibility, you can configure the heading level: + + + +```html + + + + + + + +``` + +### Disabled State + +For coming soon or unavailable plans: + + + +```html + + +``` + +### Pricing Grid Layout + +Multiple cards displayed together: + + + +## Button Types + +The component supports all standard button types from the Component Library: + +- `primary` - Main call-to-action (blue background, white text) +- `secondary` - Secondary action (transparent background, blue text) +- `danger` - Destructive actions (red theme) +- `unstyled` - Text-only button + +## Do's and Don'ts + +### ✅ Do + +- Use consistent button text like "Choose [Plan]" or "Get Started" +- Keep taglines concise and focused on key benefits +- Use annual pricing to show value (e.g., "2 months free") +- Group related plans together with consistent styling + +### ❌ Don't + +- Make taglines longer than 2 lines (they will be truncated) +- Use custom button styling - rely on the built-in types +- Mix different pricing cadences in the same comparison +- Override the 449px width - it's designed for optimal layout + +## Accessibility + +The component includes: + +- Proper heading hierarchy (`h3` for titles) +- Semantic button elements with `type="button"` +- Screen reader friendly structure +- Focus management and keyboard navigation +- High contrast color combinations diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts new file mode 100644 index 00000000000..ed2c28d8cb3 --- /dev/null +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.spec.ts @@ -0,0 +1,194 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ButtonType, IconModule, TypographyModule } from "@bitwarden/components"; + +import { PricingCardComponent } from "./pricing-card.component"; + +@Component({ + template: ` + + +

{{ titleText }}

+ +

{{ titleText }}

+ +

{{ titleText }}

+ +

{{ titleText }}

+ +
{{ titleText }}
+ +
{{ titleText }}
+
+
+ `, + imports: [PricingCardComponent, CommonModule, TypographyModule], +}) +class TestHostComponent { + titleText = "Test Plan"; + tagline = "A great plan for testing"; + price: { amount: number; cadence: "monthly" | "annually"; showPerUser?: boolean } = { + amount: 10, + cadence: "monthly", + }; + button: { type: ButtonType; text: string; disabled?: boolean } = { + text: "Select Plan", + type: "primary", + }; + features = ["Feature 1", "Feature 2", "Feature 3"]; + titleLevel: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" = "h3"; + activeBadge: { text: string; variant?: string } | undefined = undefined; + + onButtonClick() { + // Test method + } +} + +describe("PricingCardComponent", () => { + let component: PricingCardComponent; + let fixture: ComponentFixture; + let hostComponent: TestHostComponent; + let hostFixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + PricingCardComponent, + TestHostComponent, + IconModule, + TypographyModule, + CommonModule, + ], + }).compileComponents(); + + // For signal inputs, we need to set required inputs through the host component + hostFixture = TestBed.createComponent(TestHostComponent); + hostComponent = hostFixture.componentInstance; + + fixture = TestBed.createComponent(PricingCardComponent); + component = fixture.componentInstance; + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should display title and tagline", () => { + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + + // Test that the component renders and shows the tagline (which is an input, not projected content) + expect(compiled.querySelector("p").textContent).toContain("A great plan for testing"); + // Note: Title testing is skipped due to content projection limitations in Angular testing + }); + + it("should display price when provided", () => { + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + + expect(compiled.textContent).toContain("$10"); + expect(compiled.textContent).toContain("/ monthly"); + }); + + it("should display features when provided", () => { + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + + expect(compiled.textContent).toContain("Feature 1"); + expect(compiled.textContent).toContain("Feature 2"); + expect(compiled.textContent).toContain("Feature 3"); + }); + + it("should emit buttonClick when button is clicked", () => { + jest.spyOn(hostComponent, "onButtonClick"); + hostFixture.detectChanges(); + + const button = hostFixture.nativeElement.querySelector("button"); + button.click(); + + expect(hostComponent.onButtonClick).toHaveBeenCalled(); + }); + + it("should work without optional inputs", () => { + hostComponent.price = undefined as any; + hostComponent.features = undefined as any; + hostComponent.button = undefined as any; + + hostFixture.detectChanges(); + + // Note: Title content projection testing skipped due to Angular testing limitations + expect(hostFixture.nativeElement.querySelector("button")).toBeFalsy(); + }); + + it("should display per user text when showPerUser is true", () => { + hostComponent.price = { amount: 5, cadence: "monthly", showPerUser: true }; + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + + expect(compiled.textContent).toContain("$5"); + expect(compiled.textContent).toContain("per user"); + }); + + it("should use configurable heading level", () => { + hostComponent.titleLevel = "h2"; + hostFixture.detectChanges(); + + // Note: Content projection testing for configurable headings is covered in Storybook + // Angular unit tests have limitations with content projection testing + expect(component).toBeTruthy(); // Basic smoke test + }); + + it("should display bwi-check icons for features", () => { + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + const icons = compiled.querySelectorAll("i.bwi-check"); + + expect(icons.length).toBe(3); // One for each feature + }); + + it("should not display button when button input is not provided", () => { + hostComponent.button = undefined as any; + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + + expect(compiled.querySelector("button")).toBeFalsy(); + }); + + it("should display active badge when activeBadge is provided", () => { + hostComponent.activeBadge = { text: "Current Plan" }; + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + + const badge = compiled.querySelector("span[bitBadge]"); + expect(badge).toBeTruthy(); + expect(badge.textContent.trim()).toBe("Current Plan"); + }); + + it("should not display active badge when activeBadge is not provided", () => { + hostComponent.activeBadge = undefined; + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + + expect(compiled.querySelector("span[bitBadge]")).toBeFalsy(); + }); + + it("should have proper layout structure with flexbox", () => { + hostFixture.detectChanges(); + const compiled = hostFixture.nativeElement; + const cardContainer = compiled.querySelector("div"); + + expect(cardContainer.classList).toContain("tw-flex"); + expect(cardContainer.classList).toContain("tw-flex-col"); + expect(cardContainer.classList).toContain("tw-size-full"); + expect(cardContainer.classList).not.toContain("tw-block"); // Should not have conflicting display property + }); +}); diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts new file mode 100644 index 00000000000..832345de357 --- /dev/null +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.stories.ts @@ -0,0 +1,365 @@ +import { Meta, StoryObj } from "@storybook/angular"; + +import { TypographyModule } from "@bitwarden/components"; + +import { PricingCardComponent } from "./pricing-card.component"; + +export default { + title: "Billing/Pricing Card", + component: PricingCardComponent, + moduleMetadata: { + imports: [TypographyModule], + }, + args: { + tagline: "Everything you need for secure password management across all your devices", + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium-Upgrade-flows--pricing-increase-?node-id=858-44276&t=KjcXRRvf8PXJI51j-0", + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: ` + +

Premium Plan

+
+ `, + }), + args: { + tagline: "Everything you need for secure password management across all your devices", + price: { amount: 10, cadence: "monthly" }, + button: { text: "Choose Premium", type: "primary" }, + features: [ + "Unlimited passwords and passkeys", + "Secure password sharing", + "Integrated 2FA authenticator", + "Advanced 2FA options", + "Priority customer support", + ], + }, +}; + +export const WithoutPrice: Story = { + render: (args) => ({ + props: args, + template: ` + +

Free Plan

+
+ `, + }), + args: { + tagline: "Get started with essential password management features", + button: { text: "Get Started", type: "secondary" }, + features: ["Store unlimited passwords", "Access from any device", "Secure password generator"], + }, +}; + +export const WithoutFeatures: Story = { + render: (args) => ({ + props: args, + template: ` + +

Enterprise Plan

+
+ `, + }), + args: { + tagline: "Advanced security and management for your organization", + price: { amount: 3, cadence: "monthly" }, + button: { text: "Contact Sales", type: "primary" }, + }, +}; + +export const Annual: Story = { + render: (args) => ({ + props: args, + template: ` + +

Premium Plan

+
+ `, + }), + args: { + tagline: "Save more with annual billing", + price: { amount: 120, cadence: "annually" }, + button: { text: "Choose Annual", type: "primary" }, + features: [ + "All Premium features", + "2 months free with annual billing", + "Priority customer support", + ], + }, +}; + +export const Disabled: Story = { + render: (args) => ({ + props: args, + template: ` + +

Coming Soon

+
+ `, + }), + args: { + tagline: "This plan will be available soon with exciting new features", + price: { amount: 15, cadence: "monthly" }, + button: { text: "Coming Soon", type: "secondary", disabled: true }, + features: ["Advanced security features", "Enhanced collaboration tools", "Premium support"], + }, +}; + +export const LongTagline: Story = { + render: (args) => ({ + props: args, + template: ` + +

Business Plan

+
+ `, + }), + args: { + tagline: + "Comprehensive password management solution for teams and organizations that need advanced security features, detailed reporting, and enterprise-grade administration tools that scale with your business", + price: { amount: 5, cadence: "monthly", showPerUser: true }, + button: { text: "Start Business Trial", type: "primary" }, + features: [ + "Everything in Premium", + "Admin dashboard", + "Team reporting", + "Advanced permissions", + "SSO integration", + ], + }, +}; + +export const AllButtonTypes: Story = { + render: () => ({ + template: ` +
+ +

Primary Button

+
+ + +

Secondary Button

+
+ + +

Danger Button

+
+ + +

Unstyled Button

+
+
+ `, + props: {}, + }), +}; + +export const ConfigurableHeadings: Story = { + render: () => ({ + template: ` +
+ +

H2 Heading

+
+ + +

H4 Heading

+
+
+ `, + props: {}, + }), +}; + +export const PricingGrid: Story = { + render: () => ({ + template: ` +
+ +

Free

+
+ + +

Premium

+
+ + +

Business

+
+
+ `, + props: {}, + }), +}; + +export const WithoutButton: Story = { + render: (args) => ({ + props: args, + template: ` + +

Coming Soon Plan

+
+ `, + }), + args: { + tagline: "This plan will be available soon with exciting new features", + price: { amount: 15, cadence: "monthly" }, + features: ["Advanced security features", "Enhanced collaboration tools", "Premium support"], + }, +}; + +export const ActivePlan: Story = { + render: (args) => ({ + props: args, + template: ` + +

Free

+
+ `, + }), + args: { + tagline: "Your current plan with essential password management features", + features: ["Store unlimited passwords", "Access from any device", "Secure password generator"], + activeBadge: { text: "Active plan" }, + }, +}; + +export const PricingComparison: Story = { + render: () => ({ + template: ` +
+
+ +

Free

+
+
+ +
+ +

Premium

+
+
+ +
+ +

Business

+
+
+
+ `, + props: {}, + }), +}; + +export const WithButtonIcon: Story = { + render: () => ({ + template: ` +
+ + +

Premium

+
+ + + +

Business

+
+
+ `, + props: {}, + }), +}; diff --git a/libs/pricing/src/components/pricing-card/pricing-card.component.ts b/libs/pricing/src/components/pricing-card/pricing-card.component.ts new file mode 100644 index 00000000000..b727fb10673 --- /dev/null +++ b/libs/pricing/src/components/pricing-card/pricing-card.component.ts @@ -0,0 +1,42 @@ +import { Component, EventEmitter, input, Output } from "@angular/core"; + +import { + BadgeModule, + BadgeVariant, + ButtonModule, + ButtonType, + IconModule, + TypographyModule, +} from "@bitwarden/components"; + +/** + * A reusable UI-only component that displays pricing information in a card format. + * This component has no external dependencies and performs no logic - it only displays data + * and emits events when the button is clicked. + */ +@Component({ + selector: "billing-pricing-card", + templateUrl: "./pricing-card.component.html", + imports: [BadgeModule, ButtonModule, IconModule, TypographyModule], +}) +export class PricingCardComponent { + tagline = input.required(); + price = input<{ amount: number; cadence: "monthly" | "annually"; showPerUser?: boolean }>(); + button = input<{ + type: ButtonType; + text: string; + disabled?: boolean; + icon?: { type: string; position: "before" | "after" }; + }>(); + features = input(); + activeBadge = input<{ text: string; variant?: BadgeVariant }>(); + + @Output() buttonClick = new EventEmitter(); + + /** + * Handles button click events and emits the buttonClick event + */ + onButtonClick(): void { + this.buttonClick.emit(); + } +} diff --git a/libs/pricing/src/index.ts b/libs/pricing/src/index.ts new file mode 100644 index 00000000000..9eeb2de518d --- /dev/null +++ b/libs/pricing/src/index.ts @@ -0,0 +1,2 @@ +// Components +export * from "./components/pricing-card/pricing-card.component"; diff --git a/libs/pricing/src/pricing.spec.ts b/libs/pricing/src/pricing.spec.ts new file mode 100644 index 00000000000..3b66c8f0e6e --- /dev/null +++ b/libs/pricing/src/pricing.spec.ts @@ -0,0 +1,8 @@ +import * as lib from "./index"; + +describe("pricing", () => { + // This test will fail until something is exported from index.ts + it("should work", () => { + expect(lib).toBeDefined(); + }); +}); diff --git a/libs/pricing/test.setup.ts b/libs/pricing/test.setup.ts new file mode 100644 index 00000000000..159c28d2be5 --- /dev/null +++ b/libs/pricing/test.setup.ts @@ -0,0 +1,28 @@ +import { webcrypto } from "crypto"; +import "@bitwarden/ui-common/setup-jest"; + +Object.defineProperty(window, "CSS", { value: null }); +Object.defineProperty(window, "getComputedStyle", { + value: () => { + return { + display: "none", + appearance: ["-webkit-appearance"], + }; + }, +}); + +Object.defineProperty(document, "doctype", { + value: "", +}); +Object.defineProperty(document.body.style, "transform", { + value: () => { + return { + enumerable: true, + configurable: true, + }; + }, +}); + +Object.defineProperty(window, "crypto", { + value: webcrypto, +}); diff --git a/libs/pricing/tsconfig.json b/libs/pricing/tsconfig.json new file mode 100644 index 00000000000..62ebbd94647 --- /dev/null +++ b/libs/pricing/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/pricing/tsconfig.lib.json b/libs/pricing/tsconfig.lib.json new file mode 100644 index 00000000000..9cbf6736007 --- /dev/null +++ b/libs/pricing/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.js", "src/**/*.spec.ts"] +} diff --git a/libs/pricing/tsconfig.spec.json b/libs/pricing/tsconfig.spec.json new file mode 100644 index 00000000000..1275f148a18 --- /dev/null +++ b/libs/pricing/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html index 78c784b083d..4e3d9fb17d6 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.html @@ -22,6 +22,7 @@ }" [attr.data-testid]="field.value.name + '-entry'" cdkDrag + [cdkDragDisabled]="!canEdit(field.value.type)" #customFieldRow > diff --git a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts index e3612e75a1b..12e83b052bd 100644 --- a/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts +++ b/libs/vault/src/cipher-form/components/custom-fields/custom-fields.component.ts @@ -150,7 +150,9 @@ export class CustomFieldsComponent implements OnInit, AfterViewInit { canEdit(type: FieldType): boolean { return ( !this.isPartialEdit && - (type !== FieldType.Hidden || this.cipherFormContainer.originalCipherView?.viewPassword) + (type !== FieldType.Hidden || + this.cipherFormContainer.originalCipherView === null || + this.cipherFormContainer.originalCipherView.viewPassword) ); } diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts index e3d863a0af3..c41e58f679e 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.spec.ts @@ -15,6 +15,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { SelectComponent } from "@bitwarden/components"; @@ -62,16 +63,22 @@ describe("ItemDetailsSectionComponent", () => { let mockPolicyService: MockProxy; const activeAccount$ = new BehaviorSubject<{ email: string }>({ email: "test@example.com" }); - const getInitialCipherView = jest.fn(() => null); + const getInitialCipherView = jest.fn(() => null); const initializedWithCachedCipher = jest.fn(() => false); + const disableFormFields = jest.fn(); + const enableFormFields = jest.fn(); beforeEach(async () => { getInitialCipherView.mockClear(); initializedWithCachedCipher.mockClear(); + disableFormFields.mockClear(); + enableFormFields.mockClear(); cipherFormProvider = mock({ getInitialCipherView, initializedWithCachedCipher, + disableFormFields, + enableFormFields, }); i18nService = mock(); i18nService.collator = { @@ -151,7 +158,7 @@ describe("ItemDetailsSectionComponent", () => { folderId: "folder1", collectionIds: ["col1"], favorite: true, - }); + } as CipherView); await component.ngOnInit(); tick(); @@ -420,7 +427,7 @@ describe("ItemDetailsSectionComponent", () => { folderId: "folder1", collectionIds: ["col1", "col2"], favorite: true, - }); + } as CipherView); component.config.organizations = [{ id: "org1" } as Organization]; component.config.collections = [ createMockCollection("col1", "Collection 1", "org1") as CollectionView, @@ -467,7 +474,7 @@ describe("ItemDetailsSectionComponent", () => { folderId: "folder1", collectionIds: ["col1", "col2", "col3"], favorite: true, - }); + } as CipherView); component.originalCipherView = { name: "cipher1", organizationId: "org1", @@ -513,6 +520,7 @@ describe("ItemDetailsSectionComponent", () => { expect(component["collectionOptions"].map((c) => c.id)).toEqual(["col1", "col2", "col3"]); }); }); + describe("readonlyCollections", () => { beforeEach(() => { component.config.mode = "edit"; @@ -594,4 +602,81 @@ describe("ItemDetailsSectionComponent", () => { expect(result).toBeUndefined(); }); }); + + describe("form status when editing a cipher", () => { + beforeEach(() => { + component.config.mode = "edit"; + component.config.originalCipher = new Cipher(); + component.originalCipherView = { + name: "cipher1", + organizationId: null, + folderId: "folder1", + collectionIds: ["col1", "col2", "col3"], + favorite: true, + } as unknown as CipherView; + }); + + describe("when personal ownership is not allowed", () => { + beforeEach(() => { + component.config.organizationDataOwnershipDisabled = false; // disallow personal ownership + component.config.organizations = [{ id: "orgId" } as Organization]; + }); + + describe("cipher does not belong to an organization", () => { + beforeEach(() => { + getInitialCipherView.mockReturnValue(component.originalCipherView!); + }); + + it("enables organizationId", async () => { + await component.ngOnInit(); + + expect(component.itemDetailsForm.controls.organizationId.disabled).toBe(false); + }); + + it("disables the rest of the form", async () => { + await component.ngOnInit(); + + expect(disableFormFields).toHaveBeenCalled(); + expect(enableFormFields).not.toHaveBeenCalled(); + }); + }); + + describe("cipher belongs to an organization", () => { + beforeEach(() => { + component.originalCipherView.organizationId = "org-id"; + getInitialCipherView.mockReturnValue(component.originalCipherView); + }); + + it("enables the rest of the form", async () => { + await component.ngOnInit(); + + expect(disableFormFields).not.toHaveBeenCalled(); + expect(enableFormFields).toHaveBeenCalled(); + }); + }); + }); + + describe("when an ownership change is not allowed", () => { + beforeEach(() => { + component.config.organizationDataOwnershipDisabled = true; // allow personal ownership + component.originalCipherView!.organizationId = undefined; + }); + + it("disables organizationId when the cipher is owned by an organization", async () => { + component.originalCipherView!.organizationId = "orgId"; + + await component.ngOnInit(); + + expect(component.itemDetailsForm.controls.organizationId.disabled).toBe(true); + }); + + it("disables organizationId when personal ownership is allowed and the user has no organizations available", async () => { + component.config.organizations = []; + + await component.ngOnInit(); + + expect(component.itemDetailsForm.controls.organizationId.disabled).toBe(true); + }); + }); + }); }); diff --git a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts index bc5e7c43d12..978675e6ad9 100644 --- a/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/item-details/item-details-section.component.ts @@ -225,10 +225,9 @@ export class ItemDetailsSectionComponent implements OnInit { }); await this.updateCollectionOptions(this.initialValues?.collectionIds); } + this.setFormState(); - if (!this.allowOwnershipChange) { - this.itemDetailsForm.controls.organizationId.disable(); - } + this.itemDetailsForm.controls.organizationId.valueChanges .pipe( takeUntilDestroyed(this.destroyRef), @@ -241,22 +240,28 @@ export class ItemDetailsSectionComponent implements OnInit { } /** - * When the cipher does not belong to an organization but the user's organization - * requires all ciphers to be owned by an organization, disable the entire form - * until the user selects an organization. Once the organization is set, enable the form. - * Ensure to properly set the collections control state when the form is enabled. + * Updates the global form and organizationId control states. */ private setFormState() { if (this.config.originalCipher && !this.allowPersonalOwnership) { + // When editing a cipher and the user cannot have personal ownership + // and the cipher is is not within the organization - force the user to + // move the cipher within the organization first before editing any other field if (this.itemDetailsForm.controls.organizationId.value === null) { this.cipherFormContainer.disableFormFields(); this.itemDetailsForm.controls.organizationId.enable(); this.favoriteButtonDisabled = true; } else { + // The "after" from the above: When editing a cipher and the user cannot have personal ownership + // and the organization is populated - re-enable the global form. this.cipherFormContainer.enableFormFields(); this.favoriteButtonDisabled = false; this.setCollectionControlState(); } + } else if (!this.allowOwnershipChange) { + // When the user cannot change the organization field, disable the organizationId control. + // This could be because they aren't a part of an organization + this.itemDetailsForm.controls.organizationId.disable({ emitEvent: false }); } } diff --git a/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.spec.ts b/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.spec.ts index c4fbfe7640d..4693c8ebf09 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.spec.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.spec.ts @@ -2,6 +2,7 @@ import { signal } from "@angular/core"; import { TestBed } from "@angular/core/testing"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; +import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherFormCacheService } from "./default-cipher-form-cache.service"; @@ -36,9 +37,10 @@ describe("CipherFormCacheService", () => { it("updates the signal value", async () => { service = testBed.inject(CipherFormCacheService); - service.cacheCipherView({ id: "cipher-5" } as CipherView); + service.cacheCipherView(new CipherView({ id: "cipher-5" } as Cipher)); - expect(cacheSignal.set).toHaveBeenCalledWith({ id: "cipher-5" }); + expect(cacheSignal.set).toHaveBeenCalledWith(expect.any(CipherView)); // Ensure we keep the CipherView prototype + expect(cacheSignal.set).toHaveBeenCalledWith(expect.objectContaining({ id: "cipher-5" })); }); describe("initializedWithValue", () => { diff --git a/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.ts b/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.ts index 73ec6549756..25581ae5ea1 100644 --- a/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.ts +++ b/libs/vault/src/cipher-form/services/default-cipher-form-cache.service.ts @@ -1,4 +1,5 @@ import { inject, Injectable } from "@angular/core"; +import { Jsonify } from "type-fest"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -32,10 +33,10 @@ export class CipherFormCacheService { * Update the cache with the new CipherView. */ cacheCipherView(cipherView: CipherView): void { - // Create a new shallow reference to force the signal to update + // Create a new reference to force the signal to update // By default, signals use `Object.is` to determine equality // Docs: https://angular.dev/guide/signals#signal-equality-functions - this.cipherCache.set({ ...cipherView } as CipherView); + this.cipherCache.set(CipherView.fromJSON(cipherView as Jsonify)); } /** diff --git a/libs/vault/src/components/carousel/carousel.component.html b/libs/vault/src/components/carousel/carousel.component.html index 04c6e559056..778b70e15e2 100644 --- a/libs/vault/src/components/carousel/carousel.component.html +++ b/libs/vault/src/components/carousel/carousel.component.html @@ -6,18 +6,40 @@ #container > -
- +
+ +
+ +
+
diff --git a/libs/vault/src/components/carousel/carousel.component.spec.ts b/libs/vault/src/components/carousel/carousel.component.spec.ts index 1409aea0cb2..ebb38576813 100644 --- a/libs/vault/src/components/carousel/carousel.component.spec.ts +++ b/libs/vault/src/components/carousel/carousel.component.spec.ts @@ -2,6 +2,8 @@ import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component"; import { VaultCarouselComponent } from "./carousel.component"; @@ -33,6 +35,7 @@ describe("VaultCarouselComponent", () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [VaultCarouselComponent, VaultCarouselSlideComponent], + providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }], }).compileComponents(); }); @@ -48,7 +51,7 @@ describe("VaultCarouselComponent", () => { it("shows the active slides content", () => { // Set the second slide as active - fixture.debugElement.queryAll(By.css("button"))[1].nativeElement.click(); + fixture.debugElement.queryAll(By.css("button"))[2].nativeElement.click(); fixture.detectChanges(); const heading = fixture.debugElement.query(By.css("h1")).nativeElement; @@ -63,10 +66,37 @@ describe("VaultCarouselComponent", () => { it('emits "slideChange" event when slide changes', () => { jest.spyOn(component.slideChange, "emit"); - const thirdSlideButton = fixture.debugElement.queryAll(By.css("button"))[2]; + const thirdSlideButton = fixture.debugElement.queryAll(By.css("button"))[3]; thirdSlideButton.nativeElement.click(); expect(component.slideChange.emit).toHaveBeenCalledWith(2); }); + + it('advances to the next slide when the "next" button is pressed', () => { + const middleSlideButton = fixture.debugElement.queryAll(By.css("button"))[2]; + const nextButton = fixture.debugElement.queryAll(By.css("button"))[4]; + + middleSlideButton.nativeElement.click(); + + jest.spyOn(component.slideChange, "emit"); + + nextButton.nativeElement.click(); + + expect(component.slideChange.emit).toHaveBeenCalledWith(2); + }); + + it('advances to the previous slide when the "back" button is pressed', async () => { + const middleSlideButton = fixture.debugElement.queryAll(By.css("button"))[2]; + const backButton = fixture.debugElement.queryAll(By.css("button"))[0]; + + middleSlideButton.nativeElement.click(); + await new Promise((r) => setTimeout(r, 100)); // Give time for the DOM to update. + + jest.spyOn(component.slideChange, "emit"); + + backButton.nativeElement.click(); + + expect(component.slideChange.emit).toHaveBeenCalledWith(0); + }); }); diff --git a/libs/vault/src/components/carousel/carousel.component.ts b/libs/vault/src/components/carousel/carousel.component.ts index f2d211697df..fdebbebc33b 100644 --- a/libs/vault/src/components/carousel/carousel.component.ts +++ b/libs/vault/src/components/carousel/carousel.component.ts @@ -20,7 +20,9 @@ import { import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { take } from "rxjs"; -import { ButtonModule } from "@bitwarden/components"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ButtonModule, IconButtonModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { VaultCarouselButtonComponent } from "./carousel-button/carousel-button.component"; import { VaultCarouselContentComponent } from "./carousel-content/carousel-content.component"; @@ -32,9 +34,12 @@ import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.com imports: [ CdkPortalOutlet, CommonModule, + JslibModule, + IconButtonModule, ButtonModule, VaultCarouselContentComponent, VaultCarouselButtonComponent, + I18nPipe, ], }) export class VaultCarouselComponent implements AfterViewInit { @@ -97,6 +102,18 @@ export class VaultCarouselComponent implements AfterViewInit { this.slideChange.emit(index); } + protected nextSlide() { + if (this.selectedIndex < this.slides.length - 1) { + this.selectSlide(this.selectedIndex + 1); + } + } + + protected prevSlide() { + if (this.selectedIndex > 0) { + this.selectSlide(this.selectedIndex - 1); + } + } + async ngAfterViewInit() { this.keyManager = new FocusKeyManager(this.carouselButtons) .withHorizontalOrientation("ltr") diff --git a/libs/vault/src/components/carousel/carousel.stories.ts b/libs/vault/src/components/carousel/carousel.stories.ts index 521a561a19f..1e393779a6a 100644 --- a/libs/vault/src/components/carousel/carousel.stories.ts +++ b/libs/vault/src/components/carousel/carousel.stories.ts @@ -1,5 +1,6 @@ import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { ButtonComponent, TypographyModule } from "@bitwarden/components"; import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component"; @@ -11,6 +12,7 @@ export default { decorators: [ moduleMetadata({ imports: [VaultCarouselSlideComponent, TypographyModule, ButtonComponent], + providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }], }), ], } as Meta; diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.ts b/libs/vault/src/components/download-attachment/download-attachment.component.ts index 627ffafc6b2..8208887b888 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.ts @@ -92,6 +92,9 @@ export class DownloadAttachmentComponent { this.attachment, response, userId, + // When the emergency access ID is present, the cipher is being viewed via emergency access. + // Force legacy decryption in these cases. + this.emergencyAccessId ? true : false, ); this.fileDownloadService.download({ diff --git a/package-lock.json b/package-lock.json index 7c99ed85173..4bd1238b27e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -392,6 +392,10 @@ "version": "0.0.0", "license": "GPL-3.0" }, + "libs/pricing": { + "version": "0.0.1", + "license": "GPL-3.0" + }, "libs/serialization": { "name": "@bitwarden/serialization", "version": "0.0.1", @@ -4681,6 +4685,10 @@ "resolved": "libs/platform", "link": true }, + "node_modules/@bitwarden/pricing": { + "resolved": "libs/pricing", + "link": true + }, "node_modules/@bitwarden/sdk-internal": { "version": "0.2.0-main.266", "resolved": "https://registry.npmjs.org/@bitwarden/sdk-internal/-/sdk-internal-0.2.0-main.266.tgz", diff --git a/tailwind.config.js b/tailwind.config.js index dff04c897c3..bb0489ed10f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -8,6 +8,7 @@ config.content = [ "./libs/billing/src/**/*.{html,ts,mdx}", "./libs/assets/src/**/*.{html,ts}", "./libs/platform/src/**/*.{html,ts,mdx}", + "./libs/pricing/src/**/*.{html,ts,mdx}", "./libs/tools/send/send-ui/src/*.{html,ts,mdx}", "./libs/vault/src/**/*.{html,ts,mdx}", "./apps/web/src/**/*.{html,ts,mdx}", diff --git a/tsconfig.base.json b/tsconfig.base.json index 3d38f0f8210..3f903558f70 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -26,6 +26,7 @@ "@bitwarden/auth/common": ["./libs/auth/src/common"], "@bitwarden/billing": ["./libs/billing/src"], "@bitwarden/bit-common/*": ["./bitwarden_license/bit-common/src/*"], + "@bitwarden/browser/*": ["./apps/browser/src/*"], "@bitwarden/cli/*": ["./apps/cli/src/*"], "@bitwarden/client-type": ["libs/client-type/src/index.ts"], "@bitwarden/common/*": ["./libs/common/src/*"], @@ -49,6 +50,7 @@ "@bitwarden/nx-plugin": ["libs/nx-plugin/src/index.ts"], "@bitwarden/platform": ["./libs/platform/src"], "@bitwarden/platform/*": ["./libs/platform/src/*"], + "@bitwarden/pricing": ["libs/pricing/src/index.ts"], "@bitwarden/send-ui": ["./libs/tools/send/send-ui/src"], "@bitwarden/serialization": ["libs/serialization/src/index.ts"], "@bitwarden/state": ["libs/state/src/index.ts"],