diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 823cb7e25e0..bece680b9d0 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -147,7 +147,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -248,7 +248,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -359,7 +359,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 22ba3a3e7be..d1df280f764 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -124,7 +124,7 @@ jobs: awk '{print tolower($0)}')" >> $GITHUB_ENV - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -302,7 +302,7 @@ jobs: choco install nasm --no-progress - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index b1e0232cb9c..b33b128ea8e 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -174,7 +174,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -323,7 +323,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -429,7 +429,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -665,6 +665,239 @@ jobs: path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml if-no-files-found: error + windows-beta: + name: Windows Beta 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.package_version }} + _NODE_VERSION: ${{ needs.setup.outputs.node_version }} + NODE_OPTIONS: --max_old_space_size=4096 + steps: + - name: Check out repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Set up Node + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.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: Print environment + run: | + node --version + npm --version + choco --version + rustup show + + - name: Log in to Azure + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + 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 + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + 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 + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/azure-logout@main + + - name: Install Node dependencies + run: npm ci + working-directory: ./ + + - name: Download SDK Artifacts + if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }} + uses: bitwarden/gh-actions/download-artifacts@main + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + workflow: build-wasm-internal.yml + workflow_conclusion: success + branch: ${{ inputs.sdk_branch }} + artifacts: sdk-internal + repo: bitwarden/sdk-internal + path: ../sdk-internal + if_no_artifact_found: fail + + - name: Override SDK + if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }} + working-directory: ./ + run: | + ls -l ../ + npm link ../sdk-internal + + - name: Cache Native Module + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + id: cache + with: + path: | + apps/desktop/desktop_native/napi/*.node + apps/desktop/desktop_native/dist/* + key: rust-${{ runner.os }}-${{ hashFiles('apps/desktop/desktop_native/**/*') }} + + - name: Build Native Module + if: steps.cache.outputs.cache-hit != 'true' + working-directory: apps/desktop/desktop_native + run: node build.js cross-platform + + - name: Build + run: npm run build + + - name: Pack + if: ${{ needs.setup.outputs.has_secrets == 'false' }} + run: npm run pack:win:beta + + - name: Pack & Sign + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + 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 pack:win:beta + + - name: Rename appx files for store + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + run: | + Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx" ` + -Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx" + Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx" ` + -Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx" + Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx" ` + -Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx" + + - name: Fix NSIS artifact names for auto-updater + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + run: | + Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z ` + -NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z + Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z ` + -NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z + Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z ` + -NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z + + - name: Upload portable exe artifact + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe + path: apps/desktop/dist/Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe + if-no-files-found: error + + - name: Upload installer exe artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe + path: apps/desktop/dist/nsis-web/Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe + if-no-files-found: error + + - name: Upload appx ia32 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx + if-no-files-found: error + + - name: Upload store appx ia32 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx + if-no-files-found: error + + - name: Upload NSIS ia32 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z + path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z + if-no-files-found: error + + - name: Upload appx x64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx + if-no-files-found: error + + - name: Upload store appx x64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx + if-no-files-found: error + + - name: Upload NSIS x64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z + path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z + if-no-files-found: error + + - name: Upload appx ARM64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx + if-no-files-found: error + + - name: Upload store appx ARM64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx + path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx + if-no-files-found: error + + - name: Upload NSIS ARM64 artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z + path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z + if-no-files-found: error + + - name: Upload auto-update artifact + if: ${{ needs.setup.outputs.has_secrets == 'true' }} + uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0 + with: + name: ${{ needs.setup.outputs.release_channel }}-beta.yml + path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml + if-no-files-found: error + macos-build: name: MacOS Build @@ -688,7 +921,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -920,7 +1153,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' @@ -1184,7 +1417,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index dc4da7d37de..2b7b6394f24 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -57,7 +57,7 @@ jobs: echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} if: steps.get-changed-files-for-chromatic.outputs.storyFiles == 'true' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 738aef899f5..14b5d51d9ef 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -61,7 +61,7 @@ jobs: echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/nx.yml b/.github/workflows/nx.yml index 9349239a134..526c2b5d864 100644 --- a/.github/workflows/nx.yml +++ b/.github/workflows/nx.yml @@ -25,7 +25,7 @@ jobs: echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index 4f776876f17..121236c0deb 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -205,7 +205,7 @@ jobs: echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: ${{ steps.retrieve-node-version.outputs.node_version }} npm-version: "11.5.1" # FIXME: npm 11.5.1 or later is required to publish w/ OIDC; move version management to somewhere maintainable by automation diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2770c1257ea..680bfb87cfe 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT - name: Set up Node - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: cache: 'npm' cache-dependency-path: '**/package-lock.json' diff --git a/CLAUDE.md b/CLAUDE.md index 9739288aac8..dd3b6445edd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,3 +96,13 @@ enum CipherType { ``` Example: `/libs/common/src/vault/enums/cipher-type.ts` + +## References + +- [Web Clients Architecture](https://contributing.bitwarden.com/architecture/clients) +- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/) +- [Contributing Guide](https://contributing.bitwarden.com/) +- [Web Clients Setup Guide](https://contributing.bitwarden.com/getting-started/clients/) +- [Code Style](https://contributing.bitwarden.com/contributing/code-style/) +- [Security Whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/) +- [Security Definitions](https://contributing.bitwarden.com/architecture/security/definitions) diff --git a/apps/browser/project.json b/apps/browser/project.json new file mode 100644 index 00000000000..9a8df56c170 --- /dev/null +++ b/apps/browser/project.json @@ -0,0 +1,494 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "browser", + "projectType": "application", + "sourceRoot": "apps/browser/src", + "tags": ["scope:browser", "type:app"], + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "chrome-dev", + "options": { + "outputPath": "dist/apps/browser", + "webpackConfig": "apps/browser/webpack.config.js", + "tsConfig": "apps/browser/tsconfig.json", + "main": "apps/browser/src/popup/main.ts", + "target": "web", + "compiler": "tsc" + }, + "configurations": { + "chrome": { + "mode": "production", + "outputPath": "dist/apps/browser/chrome", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "chrome-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/chrome-dev", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "edge": { + "mode": "production", + "outputPath": "dist/apps/browser/edge", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "edge-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/edge-dev", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "firefox": { + "mode": "production", + "outputPath": "dist/apps/browser/firefox", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "firefox-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/firefox-dev", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "firefox-mv2": { + "mode": "production", + "outputPath": "dist/apps/browser/firefox-mv2", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "production" + } + }, + "firefox-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/firefox-mv2-dev", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "opera": { + "mode": "production", + "outputPath": "dist/apps/browser/opera", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "opera-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/opera-dev", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "safari": { + "mode": "production", + "outputPath": "dist/apps/browser/safari", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "safari-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/safari-dev", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "safari-mv2": { + "mode": "production", + "outputPath": "dist/apps/browser/safari-mv2", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "production" + } + }, + "safari-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/safari-mv2-dev", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "commercial-chrome": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-chrome", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-chrome-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-chrome-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-edge": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-edge", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-edge-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-edge-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-firefox": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-firefox", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-firefox-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-firefox-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-firefox-mv2": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-firefox-mv2", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "production" + } + }, + "commercial-firefox-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-firefox-mv2-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "commercial-opera": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-opera", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-opera-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-opera-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-safari": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-safari", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "production" + } + }, + "commercial-safari-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-safari-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-safari-mv2": { + "mode": "production", + "outputPath": "dist/apps/browser/commercial-safari-mv2", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "production" + } + }, + "commercial-safari-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-safari-mv2-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + } + } + }, + "serve": { + "executor": "@nx/webpack:webpack", + "defaultConfiguration": "chrome-dev", + "options": { + "outputPath": "dist/apps/browser", + "webpackConfig": "apps/browser/webpack.config.js", + "tsConfig": "apps/browser/tsconfig.json", + "main": "apps/browser/src/popup/main.ts", + "target": "web", + "compiler": "tsc", + "watch": true + }, + "configurations": { + "chrome-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/chrome-dev", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "firefox-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/firefox-dev", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "firefox-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/firefox-mv2-dev", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "safari-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/safari-dev", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "safari-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/safari-mv2-dev", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "edge-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/edge-dev", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "opera-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/opera-dev", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-chrome-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-chrome-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "chrome", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-firefox-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-firefox-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-firefox-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-firefox-mv2-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "firefox", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "commercial-safari-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-safari-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-safari-mv2-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-safari-mv2-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "safari", + "MANIFEST_VERSION": "2", + "NODE_ENV": "development" + } + }, + "commercial-edge-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-edge-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "edge", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + }, + "commercial-opera-dev": { + "mode": "development", + "outputPath": "dist/apps/browser/commercial-opera-dev", + "webpackConfig": "bitwarden_license/bit-browser/webpack.config.js", + "main": "bitwarden_license/bit-browser/src/popup/main.ts", + "tsConfig": "bitwarden_license/bit-browser/tsconfig.json", + "env": { + "BROWSER": "opera", + "MANIFEST_VERSION": "3", + "NODE_ENV": "development" + } + } + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/browser/jest.config.js" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/browser/**/*.ts", "apps/browser/**/*.html"] + } + } + } +} diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index d91a33c6796..9a430654a0a 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -558,7 +558,7 @@ "message": "Archive", "description": "Verb" }, - "unarchive": { + "unArchive": { "message": "Unarchive" }, "itemsInArchive": { @@ -570,11 +570,11 @@ "noItemsInArchiveDesc": { "message": "Archived items will appear here and will be excluded from general search results and autofill suggestions." }, - "itemSentToArchive": { - "message": "Item sent to archive" + "itemWasSentToArchive": { + "message": "Item was sent to archive" }, - "itemRemovedFromArchive": { - "message": "Item removed from archive" + "itemUnarchived": { + "message": "Item was unarchived" }, "archiveItem": { "message": "Archive item" @@ -5579,17 +5579,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle":{ - "message": "Phishing website" + "phishingPageTitleV2":{ + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore" : { + "message": "Learn more about phishing detection" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 0c9b4634569..a9f43045902 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -172,7 +172,9 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { } const showOnLocked = - !this.platformUtilsService.isFirefox() && !this.platformUtilsService.isSafari(); + !this.platformUtilsService.isFirefox() && + !this.platformUtilsService.isSafari() && + !(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel"); this.vaultTimeoutOptions = [ { name: this.i18nService.t("immediately"), value: 0 }, diff --git a/apps/browser/src/autofill/services/autofill.service.spec.ts b/apps/browser/src/autofill/services/autofill.service.spec.ts index f0ae8856ecd..9b0424c5cdf 100644 --- a/apps/browser/src/autofill/services/autofill.service.spec.ts +++ b/apps/browser/src/autofill/services/autofill.service.spec.ts @@ -4105,6 +4105,7 @@ describe("AutofillService", () => { }); it("returns null if the field cannot be hidden", () => { + usernameField.form = "differentFormId"; const result = autofillService["findUsernameField"]( pageDetails, passwordField, @@ -4116,6 +4117,18 @@ describe("AutofillService", () => { expect(result).toBe(null); }); + it("returns the field if the username field is in the form", () => { + const result = autofillService["findUsernameField"]( + pageDetails, + passwordField, + false, + false, + false, + ); + + expect(result).toBe(usernameField); + }); + it("returns the field if the field can be hidden", () => { const result = autofillService["findUsernameField"]( pageDetails, diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 73262962dbc..ea0fb089690 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -2286,11 +2286,16 @@ export default class AutofillService implements AutofillServiceInterface { this.findMatchingFieldIndex(f, AutoFillConstants.UsernameFieldNames) > -1; const isInSameForm = f.form === passwordField.form; + // An email or tel field in the same form as the password field is likely a qualified + // candidate for autofill, even if visibility checks are unreliable + const isQualifiedUsernameField = + f.form === passwordField.form && (f.type === "email" || f.type === "tel"); + if ( !f.disabled && (canBeReadOnly || !f.readonly) && (withoutForm || isInSameForm || includesUsernameFieldName) && - (canBeHidden || f.viewable) && + (canBeHidden || f.viewable || isQualifiedUsernameField) && (f.type === "text" || f.type === "email" || f.type === "tel") ) { // Prioritize fields in the same form as the password field diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index bd28ddfbbbf..4cd61ebead1 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -100,6 +100,8 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key- import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service"; import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service"; import { DefaultVaultTimeoutSettingsService, @@ -452,6 +454,7 @@ export default class MainBackground { taskService: TaskService; cipherEncryptionService: CipherEncryptionService; private restrictedItemTypesService: RestrictedItemTypesService; + private securityStateService: SecurityStateService; ipcContentScriptManagerService: IpcContentScriptManagerService; ipcService: IpcService; @@ -668,6 +671,8 @@ export default class MainBackground { logoutCallback, ); + this.securityStateService = new DefaultSecurityStateService(this.stateProvider); + this.popupViewCacheBackgroundService = new PopupViewCacheBackgroundService( messageListener, this.globalStateProvider, @@ -830,6 +835,7 @@ export default class MainBackground { this.accountService, this.kdfConfigService, this.keyService, + this.securityStateService, this.apiService, this.stateProvider, this.configService, @@ -984,6 +990,7 @@ export default class MainBackground { this.sendStateProvider = new SendStateProvider(this.stateProvider); this.sendService = new SendService( + this.accountService, this.keyService, this.i18nService, this.keyGenerationService, @@ -999,7 +1006,6 @@ export default class MainBackground { this.avatarService = new AvatarService(this.apiService, this.stateProvider); this.providerService = new ProviderService(this.stateProvider); - this.syncService = new DefaultSyncService( this.masterPasswordService, this.accountService, @@ -1025,6 +1031,7 @@ export default class MainBackground { this.tokenService, this.authService, this.stateProvider, + this.securityStateService, ); this.syncServiceListener = new SyncServiceListener( diff --git a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.html b/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.html deleted file mode 100644 index 5ea79c3f840..00000000000 --- a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.html +++ /dev/null @@ -1,4 +0,0 @@ -{{ "phishingPageLearnWhy"| i18n}} - - {{ "learnMore" | i18n }} - diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html index f6e3baf8766..5cac567c5c3 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html +++ b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html @@ -1,13 +1,46 @@ -
- - {{ "phishingPageTitle" | i18n }} - - +
+
+ +

{{ "phishingPageTitleV2" | i18n }}

+
- - +
+ +

{{ "phishingPageSummary" | i18n }}

+ + + {{ phishingHost$ | async }} + + + +

+ {{ "phishingPageExplanation1" | i18n }}Phishing.Database{{ "phishingPageExplanation2" | i18n }} +

+ + + {{ "phishingPageLearnMore" | i18n }} + +
+ +
+ + +
diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts index dc6ab2d329e..4712c94c89e 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts +++ b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts @@ -1,10 +1,10 @@ // eslint-disable-next-line no-restricted-imports import { CommonModule } from "@angular/common"; // eslint-disable-next-line no-restricted-imports -import { Component, OnDestroy } from "@angular/core"; +import { Component, inject } from "@angular/core"; // eslint-disable-next-line no-restricted-imports import { ActivatedRoute, RouterModule } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; +import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -13,12 +13,16 @@ import { CheckboxModule, FormFieldModule, IconModule, + IconTileComponent, LinkModule, + CalloutComponent, + TypographyModule, } from "@bitwarden/components"; import { PhishingDetectionService } from "../services/phishing-detection.service"; @Component({ + selector: "dirt-phishing-warning", standalone: true, templateUrl: "phishing-warning.component.html", imports: [ @@ -31,18 +35,16 @@ import { PhishingDetectionService } from "../services/phishing-detection.service CheckboxModule, ButtonModule, RouterModule, + IconTileComponent, + CalloutComponent, + TypographyModule, ], }) -export class PhishingWarning implements OnDestroy { - phishingHost = ""; - - private destroy$ = new Subject(); - - constructor(private activatedRoute: ActivatedRoute) { - this.activatedRoute.queryParamMap.pipe(takeUntil(this.destroy$)).subscribe((params) => { - this.phishingHost = params.get("phishingHost") || ""; - }); - } +export class PhishingWarning { + private activatedRoute = inject(ActivatedRoute); + protected phishingHost$ = this.activatedRoute.queryParamMap.pipe( + map((params) => params.get("phishingHost") || ""), + ); async closeTab() { await PhishingDetectionService.requestClosePhishingWarningPage(); @@ -50,9 +52,4 @@ export class PhishingWarning implements OnDestroy { async continueAnyway() { await PhishingDetectionService.requestContinueToDangerousUrl(); } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } } diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts new file mode 100644 index 00000000000..30d3b7faeee --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts @@ -0,0 +1,137 @@ +// TODO: This needs to be dealt with by moving this folder or updating the lint rule. +/* eslint-disable no-restricted-imports */ +import { ActivatedRoute, RouterModule } from "@angular/router"; +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { BehaviorSubject, of } from "rxjs"; + +import { DeactivatedOrg } from "@bitwarden/assets/svg"; +import { ClientType } from "@bitwarden/common/enums"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { AnonLayoutComponent, I18nMockService } from "@bitwarden/components"; + +import { PhishingWarning } from "./phishing-warning.component"; +import { ProtectedByComponent } from "./protected-by-component"; + +class MockPlatformUtilsService implements Partial { + getApplicationVersion = () => Promise.resolve("Version 2024.1.1"); + getClientType = () => ClientType.Web; +} + +/** + * Helper function to create ActivatedRoute mock with query parameters + */ +function mockActivatedRoute(queryParams: Record) { + return { + provide: ActivatedRoute, + useValue: { + queryParamMap: of({ + get: (key: string) => queryParams[key] || null, + }), + queryParams: of(queryParams), + }, + }; +} + +type StoryArgs = { + phishingHost: string; +}; + +export default { + title: "Browser/DIRT/Phishing Warning", + component: PhishingWarning, + decorators: [ + moduleMetadata({ + imports: [AnonLayoutComponent, ProtectedByComponent, RouterModule], + providers: [ + { + provide: PlatformUtilsService, + useClass: MockPlatformUtilsService, + }, + { + provide: I18nService, + useFactory: () => + new I18nMockService({ + accessing: "Accessing", + appLogoLabel: "Bitwarden logo", + phishingPageTitleV2: "Phishing attempt detected", + phishingPageCloseTabV2: "Close this tab", + phishingPageSummary: + "The site you are attempting to visit is a known malicious site and a security risk.", + phishingPageContinueV2: "Continue to this site (not recommended)", + phishingPageExplanation1: "This site was found in ", + phishingPageExplanation2: + ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + phishingPageLearnMore: "Learn more about phishing detection", + protectedBy: (product) => `Protected by ${product}`, + learnMore: "Learn more", + danger: "error", + }), + }, + { + provide: EnvironmentService, + useValue: { + environment$: new BehaviorSubject({ + getHostname() { + return "bitwarden.com"; + }, + }).asObservable(), + }, + }, + mockActivatedRoute({ phishingHost: "malicious-example.com" }), + ], + }), + ], + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + + `, + }), + argTypes: { + phishingHost: { + control: "text", + description: "The suspicious host that was blocked", + }, + }, + args: { + phishingHost: "malicious-example.com", + pageIcon: DeactivatedOrg, + }, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + phishingHost: "malicious-example.com", + }, + decorators: [ + moduleMetadata({ + providers: [mockActivatedRoute({ phishingHost: "malicious-example.com" })], + }), + ], +}; + +export const LongHostname: Story = { + args: { + phishingHost: "very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com", + }, + decorators: [ + moduleMetadata({ + providers: [ + mockActivatedRoute({ + phishingHost: + "very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com", + }), + ], + }), + ], +}; diff --git a/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.html b/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.html new file mode 100644 index 00000000000..d9f26bc9c90 --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.html @@ -0,0 +1 @@ +{{ "protectedBy" | i18n: "Bitwarden Phishing Blocker" }} diff --git a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts b/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts similarity index 63% rename from apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts rename to apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts index 1a1e6059204..298c7acd38e 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts +++ b/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts @@ -4,13 +4,12 @@ import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ButtonModule } from "@bitwarden/components"; +import { ButtonModule, LinkModule } from "@bitwarden/components"; @Component({ + selector: "dirt-phishing-protected-by", standalone: true, - templateUrl: "learn-more-component.html", - imports: [CommonModule, CommonModule, JslibModule, ButtonModule], + templateUrl: "protected-by-component.html", + imports: [CommonModule, CommonModule, JslibModule, ButtonModule, LinkModule], }) -export class LearnMoreComponent { - constructor() {} -} +export class ProtectedByComponent {} diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts index 54245ae17b4..179431b155c 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts @@ -116,15 +116,15 @@ export class PhishingDetectionService { /** * Sends a message to the phishing detection service to close the warning page */ - static requestClosePhishingWarningPage(): void { - void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close }); + static async requestClosePhishingWarningPage() { + await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close }); } /** * Sends a message to the phishing detection service to continue to the caught url */ static async requestContinueToDangerousUrl() { - void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue }); + await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue }); } /** diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 17a812f451c..cb5e597e78c 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -24,7 +24,6 @@ import { VaultIcon, LockIcon, TwoFactorAuthSecurityKeyIcon, - DeactivatedOrg, } from "@bitwarden/assets/svg"; import { LoginComponent, @@ -54,8 +53,8 @@ import { BlockedDomainsComponent } from "../autofill/popup/settings/blocked-doma import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component"; import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component"; import { PremiumV2Component } from "../billing/popup/settings/premium-v2.component"; -import { LearnMoreComponent } from "../dirt/phishing-detection/pages/learn-more-component"; import { PhishingWarning } from "../dirt/phishing-detection/pages/phishing-warning.component"; +import { ProtectedByComponent } from "../dirt/phishing-detection/pages/protected-by-component"; import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import BrowserPopupUtils from "../platform/browser/browser-popup-utils"; import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service"; @@ -718,14 +717,13 @@ const routes: Routes = [ }, { path: "", - component: LearnMoreComponent, + component: ProtectedByComponent, outlet: "secondary", }, ], data: { - pageIcon: DeactivatedOrg, - pageTitle: "Bitwarden blocked it!", - pageSubtitle: "Bitwarden blocked a known phishing site from loading.", + hideIcon: true, + hideBackgroundIllustration: true, showReadonlyHostname: true, } satisfies AnonLayoutWrapperData, }, diff --git a/apps/browser/src/popup/scss/base.scss b/apps/browser/src/popup/scss/base.scss index b3d14e65061..01b9d3f05d5 100644 --- a/apps/browser/src/popup/scss/base.scss +++ b/apps/browser/src/popup/scss/base.scss @@ -382,7 +382,7 @@ app-root { } } -main:not(popup-page main) { +main:not(popup-page main):not(auth-anon-layout main) { position: absolute; top: 44px; bottom: 0; diff --git a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts index ebcd8707597..83535b09e66 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/item-more-options/item-more-options.component.ts @@ -302,7 +302,7 @@ export class ItemMoreOptionsComponent { await this.cipherArchiveService.archiveWithServer(this.cipher.id as CipherId, activeUserId); this.toastService.showToast({ variant: "success", - message: this.i18nService.t("itemSentToArchive"), + message: this.i18nService.t("itemWasSentToArchive"), }); } } diff --git a/apps/browser/src/vault/popup/settings/archive.component.html b/apps/browser/src/vault/popup/settings/archive.component.html index 5fb57814fff..faaf0243fc7 100644 --- a/apps/browser/src/vault/popup/settings/archive.component.html +++ b/apps/browser/src/vault/popup/settings/archive.component.html @@ -49,7 +49,7 @@ {{ "clone" | i18n }} + @if (!hideContinueWithoutUpgradingButton()) { + + }
diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts index 93cfa1da20f..27e69fcf0d4 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.spec.ts @@ -146,4 +146,46 @@ describe("UpgradeAccountComponent", () => { expect(result).toBe(false); }); }); + + describe("hideContinueWithoutUpgradingButton", () => { + it("should show the continue without upgrading button by default", () => { + const button = fixture.nativeElement.querySelector('button[bitLink][linkType="primary"]'); + expect(button).toBeTruthy(); + }); + + it("should hide the continue without upgrading button when input is true", async () => { + TestBed.resetTestingModule(); + + mockI18nService.t.mockImplementation((key) => key); + mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue( + of(mockPricingTiers), + ); + + await TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + UpgradeAccountComponent, + PricingCardComponent, + CdkTrapFocus, + ], + providers: [ + { provide: I18nService, useValue: mockI18nService }, + { provide: SubscriptionPricingService, useValue: mockSubscriptionPricingService }, + ], + }) + .overrideComponent(UpgradeAccountComponent, { + remove: { imports: [BillingServicesModule] }, + }) + .compileComponents(); + + const customFixture = TestBed.createComponent(UpgradeAccountComponent); + customFixture.componentRef.setInput("hideContinueWithoutUpgradingButton", true); + customFixture.detectChanges(); + + const button = customFixture.nativeElement.querySelector( + 'button[bitLink][linkType="primary"]', + ); + expect(button).toBeNull(); + }); + }); }); diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts index e9cb390d604..a9d9b959282 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-account/upgrade-account.component.ts @@ -1,6 +1,6 @@ import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, OnInit, output, signal } from "@angular/core"; +import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -52,6 +52,8 @@ type CardDetails = { templateUrl: "./upgrade-account.component.html", }) export class UpgradeAccountComponent implements OnInit { + dialogTitleMessageOverride = input(null); + hideContinueWithoutUpgradingButton = input(false); planSelected = output(); closeClicked = output(); protected loading = signal(true); @@ -62,6 +64,10 @@ export class UpgradeAccountComponent implements OnInit { protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium; protected closeStatus = UpgradeAccountStatus.Closed; + protected dialogTitle = computed(() => { + return this.dialogTitleMessageOverride() || "individualUpgradeWelcomeMessage"; + }); + constructor( private i18nService: I18nService, private subscriptionPricingService: SubscriptionPricingService, diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.html new file mode 100644 index 00000000000..115c0be86a2 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.html @@ -0,0 +1,14 @@ +
+
+ + +
+
diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts new file mode 100644 index 00000000000..c24e4fbdade --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component.ts @@ -0,0 +1,36 @@ +import { Component, inject } from "@angular/core"; +import { firstValueFrom, lastValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DialogService } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { UnifiedUpgradeDialogComponent } from "../../unified-upgrade-dialog/unified-upgrade-dialog.component"; + +@Component({ + selector: "app-upgrade-nav-button", + imports: [I18nPipe], + templateUrl: "./upgrade-nav-button.component.html", + standalone: true, +}) +export class UpgradeNavButtonComponent { + private dialogService = inject(DialogService); + private accountService = inject(AccountService); + + openUpgradeDialog = async () => { + const account = await firstValueFrom(this.accountService.activeAccount$); + if (!account) { + return; + } + + const dialogRef = UnifiedUpgradeDialogComponent.open(this.dialogService, { + data: { + account, + planSelectionStepTitleOverride: "upgradeYourPlan", + hideContinueWithoutUpgradingButton: true, + }, + }); + + await lastValueFrom(dialogRef.closed); + }; +} diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.stories.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.stories.ts new file mode 100644 index 00000000000..abc48ff2528 --- /dev/null +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.stories.ts @@ -0,0 +1,65 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { DialogService, I18nMockService } from "@bitwarden/components"; +import { UpgradeNavButtonComponent } from "@bitwarden/web-vault/app/billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component"; + +export default { + title: "Billing/Upgrade Navigation Button", + component: UpgradeNavButtonComponent, + decorators: [ + moduleMetadata({ + providers: [ + { + provide: I18nService, + useFactory: () => { + return new I18nMockService({ + upgradeYourPlan: "Upgrade your plan", + }); + }, + }, + { + provide: DialogService, + useValue: { + open: () => ({ + closed: of({}), + }), + }, + }, + { + provide: AccountService, + useValue: { + activeAccount$: of({ + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }), + }, + }, + ], + }), + ], + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium---business-upgrade-flows?node-id=858-44274&t=EiNqDGuccfhF14on-1", + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: ` +
+ +
+ `, + }), +}; diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts index 49f3e10c582..653a77dccdc 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.spec.ts @@ -1,8 +1,14 @@ import { TestBed } from "@angular/core/testing"; import { mock, mockReset } from "jest-mock-extended"; +import { of } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationUserType } from "@bitwarden/common/admin-console/enums"; +import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { OrganizationBillingServiceAbstraction } from "@bitwarden/common/billing/abstractions"; import { PaymentMethodType, PlanType } from "@bitwarden/common/billing/enums"; import { SyncService } from "@bitwarden/common/platform/sync"; @@ -22,6 +28,8 @@ describe("UpgradePaymentService", () => { const mockLogService = mock(); const mockApiService = mock(); const mockSyncService = mock(); + const mockOrganizationService = mock(); + const mockAccountService = mock(); mockApiService.refreshIdentityToken.mockResolvedValue({}); mockSyncService.fullSync.mockResolvedValue(true); @@ -94,6 +102,11 @@ describe("UpgradePaymentService", () => { mockReset(mockAccountBillingClient); mockReset(mockTaxClient); mockReset(mockLogService); + mockReset(mockOrganizationService); + mockReset(mockAccountService); + + mockAccountService.activeAccount$ = of(null); + mockOrganizationService.organizations$.mockReturnValue(of([])); TestBed.configureTestingModule({ providers: [ @@ -108,12 +121,204 @@ describe("UpgradePaymentService", () => { { provide: LogService, useValue: mockLogService }, { provide: ApiService, useValue: mockApiService }, { provide: SyncService, useValue: mockSyncService }, + { provide: OrganizationService, useValue: mockOrganizationService }, + { provide: AccountService, useValue: mockAccountService }, ], }); sut = TestBed.inject(UpgradePaymentService); }); + describe("userIsOwnerOfFreeOrg$", () => { + it("should return true when user is owner of a free organization", (done) => { + // Arrange + mockReset(mockAccountService); + mockReset(mockOrganizationService); + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + + const paidOrgData = { + id: "org-1", + name: "Paid Org", + useTotp: true, // useTotp = true means NOT free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const freeOrgData = { + id: "org-2", + name: "Free Org", + useTotp: false, // useTotp = false means IS free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const paidOrg = new Organization(paidOrgData); + const freeOrg = new Organization(freeOrgData); + const mockOrganizations = [paidOrg, freeOrg]; + + mockAccountService.activeAccount$ = of(mockAccount); + mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations)); + + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockApiService, + mockSyncService, + mockOrganizationService, + mockAccountService, + ); + + // Act & Assert + service.userIsOwnerOfFreeOrg$.subscribe((result) => { + expect(result).toBe(true); + done(); + }); + }); + + it("should return false when user is not owner of any free organization", (done) => { + // Arrange + mockReset(mockAccountService); + mockReset(mockOrganizationService); + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + + const paidOrgData = { + id: "org-1", + name: "Paid Org", + useTotp: true, // useTotp = true means NOT free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const freeOrgData = { + id: "org-2", + name: "Free Org", + useTotp: false, // useTotp = false means IS free + type: OrganizationUserType.User, // Not owner + } as OrganizationData; + + const paidOrg = new Organization(paidOrgData); + const freeOrg = new Organization(freeOrgData); + const mockOrganizations = [paidOrg, freeOrg]; + + mockAccountService.activeAccount$ = of(mockAccount); + mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations)); + + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockApiService, + mockSyncService, + mockOrganizationService, + mockAccountService, + ); + + // Act & Assert + service.userIsOwnerOfFreeOrg$.subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + + it("should return false when user has no organizations", (done) => { + // Arrange + mockReset(mockAccountService); + mockReset(mockOrganizationService); + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + + mockAccountService.activeAccount$ = of(mockAccount); + mockOrganizationService.organizations$.mockReturnValue(of([])); + + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockApiService, + mockSyncService, + mockOrganizationService, + mockAccountService, + ); + + // Act & Assert + service.userIsOwnerOfFreeOrg$.subscribe((result) => { + expect(result).toBe(false); + done(); + }); + }); + }); + + describe("adminConsoleRouteForOwnedOrganization$", () => { + it("should return the admin console route for the first free organization the user owns", (done) => { + // Arrange + mockReset(mockAccountService); + mockReset(mockOrganizationService); + + const mockAccount: Account = { + id: "user-id" as UserId, + email: "test@example.com", + name: "Test User", + emailVerified: true, + }; + + const paidOrgData = { + id: "org-1", + name: "Paid Org", + useTotp: true, // useTotp = true means NOT free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const freeOrgData = { + id: "org-2", + name: "Free Org", + useTotp: false, // useTotp = false means IS free + type: OrganizationUserType.Owner, + } as OrganizationData; + + const paidOrg = new Organization(paidOrgData); + const freeOrg = new Organization(freeOrgData); + const mockOrganizations = [paidOrg, freeOrg]; + + mockAccountService.activeAccount$ = of(mockAccount); + mockOrganizationService.organizations$.mockReturnValue(of(mockOrganizations)); + + const service = new UpgradePaymentService( + mockOrganizationBillingService, + mockAccountBillingClient, + mockTaxClient, + mockLogService, + mockApiService, + mockSyncService, + mockOrganizationService, + mockAccountService, + ); + + // Act & Assert + service.adminConsoleRouteForOwnedOrganization$.subscribe((result) => { + expect(result).toBe("/organizations/org-2/billing/subscription"); + done(); + }); + }); + }); + describe("calculateEstimatedTax", () => { it("should calculate tax for premium plan", async () => { // Arrange diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts index cabd148a539..11dd10d4bb8 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/services/upgrade-payment.service.ts @@ -1,8 +1,12 @@ import { Injectable } from "@angular/core"; +import { defaultIfEmpty, find, map, mergeMap, Observable, switchMap } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { OrganizationResponse } from "@bitwarden/common/admin-console/models/response/organization.response"; -import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { OrganizationBillingServiceAbstraction, SubscriptionInformation, @@ -53,8 +57,28 @@ export class UpgradePaymentService { private logService: LogService, private apiService: ApiService, private syncService: SyncService, + private organizationService: OrganizationService, + private accountService: AccountService, ) {} + userIsOwnerOfFreeOrg$: Observable = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((id) => this.organizationService.organizations$(id)), + mergeMap((userOrganizations) => userOrganizations), + find((org) => org.isFreeOrg && org.isOwner), + defaultIfEmpty(false), + map((value) => value instanceof Organization), + ); + + adminConsoleRouteForOwnedOrganization$: Observable = + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((id) => this.organizationService.organizations$(id)), + mergeMap((userOrganizations) => userOrganizations), + find((org) => org.isFreeOrg && org.isOwner), + map((org) => `/organizations/${org!.id}/billing/subscription`), + ); + /** * Calculate estimated tax for the selected plan */ diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html index 0198f3baebb..7b92ae10947 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.html @@ -4,6 +4,22 @@
@if (isFamiliesPlan) { + @if (userIsOwnerOfFreeOrg$ | async) { +
+ + {{ "formWillCreateNewFamiliesOrgMessage" | i18n }} + + {{ "upgradeNow" | i18n }} + + + +
+ }
{{ "organizationName" | i18n }} @@ -26,6 +42,7 @@
@if (passwordManager) { diff --git a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts index 8aaced5e1fc..33568435d01 100644 --- a/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts +++ b/apps/web/src/app/billing/individual/upgrade/upgrade-payment/upgrade-payment.component.ts @@ -104,6 +104,10 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { private upgradePaymentService: UpgradePaymentService, ) {} + protected userIsOwnerOfFreeOrg$ = this.upgradePaymentService.userIsOwnerOfFreeOrg$; + protected adminConsoleRouteForOwnedOrganization$ = + this.upgradePaymentService.adminConsoleRouteForOwnedOrganization$; + async ngOnInit(): Promise { if (!this.isFamiliesPlan) { this.formGroup.controls.organizationName.disable(); @@ -118,27 +122,24 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { tier: this.selectedPlanId(), details: planDetails, }; + this.passwordManager = { + name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", + cost: this.selectedPlan.details.passwordManager.annualPrice, + quantity: 1, + cadence: "year", + }; + + this.upgradeToMessage = this.i18nService.t( + this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium", + ); + + this.estimatedTax = 0; + } else { + this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); + return; } }); - if (!this.selectedPlan) { - this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null }); - return; - } - - this.passwordManager = { - name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership", - cost: this.selectedPlan.details.passwordManager.annualPrice, - quantity: 1, - cadence: "year", - }; - - this.upgradeToMessage = this.i18nService.t( - this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium", - ); - - this.estimatedTax = 0; - this.formGroup.valueChanges .pipe(debounceTime(1000), takeUntilDestroyed(this.destroyRef)) .subscribe(() => this.refreshSalesTax()); @@ -146,7 +147,9 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit { } ngAfterViewInit(): void { - this.cartSummaryComponent.isExpanded.set(false); + if (this.cartSummaryComponent) { + this.cartSummaryComponent.isExpanded.set(false); + } } protected get isPremiumPlan(): boolean { diff --git a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts index 6bb262152ed..79d4057fdd7 100644 --- a/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts +++ b/apps/web/src/app/billing/organizations/organization-subscription-cloud.component.ts @@ -148,19 +148,17 @@ export class OrganizationSubscriptionCloudComponent implements OnInit, OnDestroy const isResoldOrganizationOwner = this.userOrg.hasReseller && this.userOrg.isOwner; const isMSPUser = this.userOrg.hasProvider && this.userOrg.isProviderUser; - const metadata = await this.billingApiService.getOrganizationBillingMetadata( - this.organizationId, - ); - this.organizationIsManagedByConsolidatedBillingMSP = - this.userOrg.hasProvider && metadata.isManaged; + this.userOrg.hasProvider && this.userOrg.hasBillableProvider; this.showSubscription = isIndependentOrganizationOwner || isResoldOrganizationOwner || (isMSPUser && !this.organizationIsManagedByConsolidatedBillingMSP); - this.showSelfHost = metadata.isEligibleForSelfHost; + this.showSelfHost = + this.userOrg.productTierType === ProductTierType.Families || + this.userOrg.productTierType === ProductTierType.Enterprise; if (this.showSubscription) { this.sub = await this.organizationApiService.getSubscription(this.organizationId); diff --git a/apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts b/apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts new file mode 100644 index 00000000000..7504b599e16 --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/model/public-key-encryption-key-pair-request.model.ts @@ -0,0 +1,19 @@ +import { UnsignedPublicKey, WrappedPrivateKey } from "@bitwarden/common/key-management/types"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SignedPublicKey } from "@bitwarden/sdk-internal"; + +export class PublicKeyEncryptionKeyPairRequestModel { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: string; + signedPublicKey: SignedPublicKey | null; + + constructor( + wrappedPrivateKey: WrappedPrivateKey, + publicKey: UnsignedPublicKey, + signedPublicKey: SignedPublicKey | null, + ) { + this.wrappedPrivateKey = wrappedPrivateKey; + this.publicKey = Utils.fromBufferToB64(publicKey); + this.signedPublicKey = signedPublicKey; + } +} diff --git a/apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts b/apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts new file mode 100644 index 00000000000..2dbf75e2ff8 --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/model/signature-key-pair-request-request.model.ts @@ -0,0 +1,18 @@ +import { VerifyingKey, WrappedSigningKey } from "@bitwarden/common/key-management/types"; +import { SignatureAlgorithm } from "@bitwarden/sdk-internal"; + +export class SignatureKeyPairRequestModel { + signatureAlgorithm: SignatureAlgorithm; + wrappedSigningKey: WrappedSigningKey; + verifyingKey: VerifyingKey; + + constructor( + signingKey: WrappedSigningKey, + verifyingKey: VerifyingKey, + signingKeyAlgorithm: SignatureAlgorithm, + ) { + this.signatureAlgorithm = signingKeyAlgorithm; + this.wrappedSigningKey = signingKey; + this.verifyingKey = verifyingKey; + } +} diff --git a/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts b/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts index 1c9b6c9ceca..2c8964a3588 100644 --- a/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts +++ b/apps/web/src/app/key-management/key-rotation/request/account-keys.request.ts @@ -1,10 +1,70 @@ -export class AccountKeysRequest { - // Other keys encrypted by the userkey - userKeyEncryptedAccountPrivateKey: string; - accountPublicKey: string; +import { SecurityStateRequest } from "@bitwarden/common/key-management/security-state/request/security-state.request"; +import { WrappedPrivateKey } from "@bitwarden/common/key-management/types"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { PureCrypto } from "@bitwarden/sdk-internal"; - constructor(userKeyEncryptedAccountPrivateKey: string, accountPublicKey: string) { - this.userKeyEncryptedAccountPrivateKey = userKeyEncryptedAccountPrivateKey; - this.accountPublicKey = accountPublicKey; +import { PublicKeyEncryptionKeyPairRequestModel } from "../model/public-key-encryption-key-pair-request.model"; +import { SignatureKeyPairRequestModel } from "../model/signature-key-pair-request-request.model"; +import { V1UserCryptographicState } from "../types/v1-cryptographic-state"; +import { V2UserCryptographicState } from "../types/v2-cryptographic-state"; + +// This request contains other account-owned keys that are encrypted with the user key. +export class AccountKeysRequest { + /** + * @deprecated + */ + userKeyEncryptedAccountPrivateKey: WrappedPrivateKey | null = null; + /** + * @deprecated + */ + accountPublicKey: string | null = null; + + publicKeyEncryptionKeyPair: PublicKeyEncryptionKeyPairRequestModel | null = null; + signatureKeyPair: SignatureKeyPairRequestModel | null = null; + securityState: SecurityStateRequest | null = null; + + constructor() {} + + static fromV1CryptographicState(state: V1UserCryptographicState): AccountKeysRequest { + const request = new AccountKeysRequest(); + request.userKeyEncryptedAccountPrivateKey = state.publicKeyEncryptionKeyPair.wrappedPrivateKey; + request.accountPublicKey = Utils.fromBufferToB64(state.publicKeyEncryptionKeyPair.publicKey); + request.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel( + state.publicKeyEncryptionKeyPair.wrappedPrivateKey, + state.publicKeyEncryptionKeyPair.publicKey, + null, + ); + + return request; + } + + static async fromV2CryptographicState( + state: V2UserCryptographicState, + ): Promise { + // Ensure the SDK is loaded, since it is used to derive the signature algorithm. + await SdkLoadService.Ready; + + const request = new AccountKeysRequest(); + request.userKeyEncryptedAccountPrivateKey = state.publicKeyEncryptionKeyPair.wrappedPrivateKey!; + request.accountPublicKey = Utils.fromBufferToB64(state.publicKeyEncryptionKeyPair.publicKey); + request.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairRequestModel( + state.publicKeyEncryptionKeyPair.wrappedPrivateKey, + state.publicKeyEncryptionKeyPair.publicKey, + state.publicKeyEncryptionKeyPair.signedPublicKey, + ); + request.signatureKeyPair = new SignatureKeyPairRequestModel( + state.signatureKeyPair.wrappedSigningKey, + state.signatureKeyPair.verifyingKey, + PureCrypto.key_algorithm_for_verifying_key( + Utils.fromB64ToArray(state.signatureKeyPair.verifyingKey), + ), + ); + request.securityState = new SecurityStateRequest( + state.securityState.securityState, + state.securityState.securityStateVersion, + ); + + return request; } } diff --git a/apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts b/apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts new file mode 100644 index 00000000000..220bdd37752 --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/types/v1-cryptographic-state.ts @@ -0,0 +1,10 @@ +import { UnsignedPublicKey, WrappedPrivateKey } from "@bitwarden/common/key-management/types"; +import { UserKey } from "@bitwarden/common/types/key"; + +export type V1UserCryptographicState = { + userKey: UserKey; + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: UnsignedPublicKey; + }; +}; diff --git a/apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts b/apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts new file mode 100644 index 00000000000..da52d6d6eef --- /dev/null +++ b/apps/web/src/app/key-management/key-rotation/types/v2-cryptographic-state.ts @@ -0,0 +1,49 @@ +import { + SignedSecurityState, + UnsignedPublicKey, + VerifyingKey, + WrappedPrivateKey, + WrappedSigningKey, +} from "@bitwarden/common/key-management/types"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserKey } from "@bitwarden/common/types/key"; +import { SignedPublicKey, UserCryptoV2KeysResponse } from "@bitwarden/sdk-internal"; + +export type V2UserCryptographicState = { + userKey: UserKey; + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: UnsignedPublicKey; + signedPublicKey: SignedPublicKey; + }; + signatureKeyPair: { + wrappedSigningKey: WrappedSigningKey; + verifyingKey: VerifyingKey; + }; + securityState: { + securityState: SignedSecurityState; + securityStateVersion: number; + }; +}; + +export function fromSdkV2KeysToV2UserCryptographicState( + response: UserCryptoV2KeysResponse, +): V2UserCryptographicState { + return { + userKey: SymmetricCryptoKey.fromString(response.userKey) as UserKey, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: response.privateKey as WrappedPrivateKey, + publicKey: Utils.fromB64ToArray(response.publicKey) as UnsignedPublicKey, + signedPublicKey: response.signedPublicKey, + }, + signatureKeyPair: { + wrappedSigningKey: response.signingKey as WrappedSigningKey, + verifyingKey: response.verifyingKey as VerifyingKey, + }, + securityState: { + securityState: response.securityState as SignedSecurityState, + securityStateVersion: response.securityVersion, + }, + }; +} diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts index 0ffa48048f6..b790fb8409a 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.spec.ts @@ -4,6 +4,7 @@ import { BehaviorSubject } from "rxjs"; import { OrganizationUserResetPasswordWithIdRequest } from "@bitwarden/admin-console/common"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { WebauthnRotateCredentialRequest } from "@bitwarden/common/auth/models/request/webauthn-rotate-credential.request"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { @@ -11,10 +12,22 @@ import { EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { + SignedPublicKey, + SignedSecurityState, + UnsignedPublicKey, + VerifyingKey, + WrappedPrivateKey, + WrappedSigningKey, +} from "@bitwarden/common/key-management/types"; import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; 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 { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { HashPurpose } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SendWithIdRequest } from "@bitwarden/common/tools/send/models/request/send-with-id.request"; @@ -33,13 +46,14 @@ import { PBKDF2KdfConfig, KdfConfigService, KdfConfig, + KdfType, } from "@bitwarden/key-management"; import { AccountRecoveryTrustComponent, EmergencyAccessTrustComponent, KeyRotationTrustInfoComponent, } from "@bitwarden/key-management-ui"; -import { PureCrypto } from "@bitwarden/sdk-internal"; +import { BitwardenClient, PureCrypto } from "@bitwarden/sdk-internal"; import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; import { WebauthnLoginAdminService } from "../../auth"; @@ -48,11 +62,18 @@ import { EmergencyAccessStatusType } from "../../auth/emergency-access/enums/eme import { EmergencyAccessType } from "../../auth/emergency-access/enums/emergency-access-type"; import { EmergencyAccessWithIdRequest } from "../../auth/emergency-access/request/emergency-access-update.request"; +import { AccountKeysRequest } from "./request/account-keys.request"; import { MasterPasswordUnlockDataRequest } from "./request/master-password-unlock-data.request"; import { UnlockDataRequest } from "./request/unlock-data.request"; import { UserDataRequest } from "./request/userdata.request"; +import { V1UserCryptographicState } from "./types/v1-cryptographic-state"; +import { V2UserCryptographicState } from "./types/v2-cryptographic-state"; import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; -import { UserKeyRotationService } from "./user-key-rotation.service"; +import { + UserKeyRotationService, + V1CryptographicStateParameters, + V2CryptographicStateParameters, +} from "./user-key-rotation.service"; const initialPromptedOpenTrue = jest.fn(); initialPromptedOpenTrue.mockReturnValue({ closed: new BehaviorSubject(true) }); @@ -120,6 +141,21 @@ function createMockWebauthn(id: string): any { } as WebauthnRotateCredentialRequest; } +const TEST_VECTOR_USER_KEY_V1 = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; +const TEST_VECTOR_PRIVATE_KEY_V1 = + "2.AAAw2vTUePO+CCyokcIfVw==|DTBNlJ5yVsV2Bsk3UU3H6Q==|YvFBff5gxWqM+UsFB6BKimKxhC32AtjF3IStpU1Ijwg=" as WrappedPrivateKey; +const TEST_VECTOR_PUBLIC_KEY_V1 = Utils.fromBufferToB64(new Uint8Array(400)); +const TEST_VECTOR_PRIVATE_KEY_V1_ROTATED = + "2.AAAw2vTUePO+CCyokcIfVw==|DTBNlJ5yVsV2Bsk3UU3H6Q==|AAAAff5gxWqM+UsFB6BKimKxhC32AtjF3IStpU1Ijwg=" as WrappedPrivateKey; + +const TEST_VECTOR_USER_KEY_V2 = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey; +const TEST_VECTOR_PRIVATE_KEY_V2 = "7.AAAw2vTUePO+CCyokcIfVw==" as WrappedPrivateKey; +const TEST_VECTOR_SIGNING_KEY_V2 = "7.AAAw2vTUePO+CCyokcIfVw==" as WrappedSigningKey; +const TEST_VECTOR_VERIFYING_KEY_V2 = "AAAw2vTUePO+CCyokcIfVw==" as VerifyingKey; +const TEST_VECTOR_SECURITY_STATE_V2 = "AAAw2vTUePO+CCyokcIfVw==" as SignedSecurityState; +const TEST_VECTOR_PUBLIC_KEY_V2 = Utils.fromBufferToB64(new Uint8Array(400)); +const TEST_VECTOR_SIGNED_PUBLIC_KEY_V2 = "AAAw2vTUePO+CCyokcIfVw==" as SignedPublicKey; + class TestUserKeyRotationService extends UserKeyRotationService { override rotateUserKeyMasterPasswordAndEncryptedData( currentMasterPassword: string, @@ -138,22 +174,17 @@ class TestUserKeyRotationService extends UserKeyRotationService { return super.ensureIsAllowedToRotateUserKey(); } override getNewAccountKeysV1( - currentUserKey: UserKey, - currentUserKeyWrappedPrivateKey: EncString, - ): Promise<{ - userKey: UserKey; - asymmetricEncryptionKeys: { wrappedPrivateKey: EncString; publicKey: string }; - }> { - return super.getNewAccountKeysV1(currentUserKey, currentUserKeyWrappedPrivateKey); + cryptographicStateParameters: V1CryptographicStateParameters, + ): Promise { + return super.getNewAccountKeysV1(cryptographicStateParameters); } override getNewAccountKeysV2( - currentUserKey: UserKey, - currentUserKeyWrappedPrivateKey: EncString, - ): Promise<{ - userKey: UserKey; - asymmetricEncryptionKeys: { wrappedPrivateKey: EncString; publicKey: string }; - }> { - return super.getNewAccountKeysV2(currentUserKey, currentUserKeyWrappedPrivateKey); + userId: UserId, + kdfConfig: KdfConfig, + email: string, + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters, + ): Promise { + return super.getNewAccountKeysV2(userId, kdfConfig, email, cryptographicStateParameters); } override createMasterPasswordUnlockDataRequest( userKey: UserKey, @@ -176,8 +207,8 @@ class TestUserKeyRotationService extends UserKeyRotationService { masterKeyKdfConfig: KdfConfig; masterPasswordHint: string; }, - trustedEmergencyAccessGranteesPublicKeys: Uint8Array[], - trustedOrganizationPublicKeys: Uint8Array[], + trustedEmergencyAccessGranteesPublicKeys: UnsignedPublicKey[], + trustedOrganizationPublicKeys: UnsignedPublicKey[], ): Promise { return super.getAccountUnlockDataRequest( userId, @@ -190,8 +221,8 @@ class TestUserKeyRotationService extends UserKeyRotationService { } override verifyTrust(user: Account): Promise<{ wasTrustDenied: boolean; - trustedOrganizationPublicKeys: Uint8Array[]; - trustedEmergencyAccessUserPublicKeys: Uint8Array[]; + trustedOrganizationPublicKeys: UnsignedPublicKey[]; + trustedEmergencyAccessUserPublicKeys: UnsignedPublicKey[]; }> { return super.verifyTrust(user); } @@ -202,14 +233,6 @@ class TestUserKeyRotationService extends UserKeyRotationService { ): Promise { return super.getAccountDataRequest(originalUserKey, newUnencryptedUserKey, user); } - override makeNewUserKeyV1(oldUserKey: UserKey): Promise { - return super.makeNewUserKeyV1(oldUserKey); - } - override makeNewUserKeyV2( - oldUserKey: UserKey, - ): Promise<{ isUpgrading: boolean; newUserKey: UserKey }> { - return super.makeNewUserKeyV2(oldUserKey); - } override isV1User(userKey: UserKey): boolean { return super.isV1User(userKey); } @@ -227,6 +250,13 @@ class TestUserKeyRotationService extends UserKeyRotationService { masterKeySalt, ); } + override getCryptographicStateForUser(user: Account): Promise<{ + masterKeyKdfConfig: KdfConfig; + masterKeySalt: string; + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters; + }> { + return super.getCryptographicStateForUser(user); + } } describe("KeyRotationService", () => { @@ -251,6 +281,8 @@ describe("KeyRotationService", () => { let mockI18nService: MockProxy; let mockCryptoFunctionService: MockProxy; let mockKdfConfigService: MockProxy; + let mockSdkClientFactory: MockProxy; + let mockSecurityStateService: MockProxy; const mockUser = { id: "mockUserId" as UserId, @@ -261,6 +293,9 @@ describe("KeyRotationService", () => { const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("test-public-key")]; + const mockMakeKeysForUserCryptoV2 = jest.fn(); + const mockGetV2RotatedAccountKeys = jest.fn(); + beforeAll(() => { mockApiService = mock(); mockCipherService = mock(); @@ -271,7 +306,7 @@ describe("KeyRotationService", () => { mockTrustedPublicKeys.map((key) => { return { publicKey: key, - id: "mockId", + id: "00000000-0000-0000-0000-000000000000" as UserId, granteeId: "mockGranteeId", name: "mockName", email: "mockEmail", @@ -306,6 +341,17 @@ describe("KeyRotationService", () => { mockDialogService = mock(); mockCryptoFunctionService = mock(); mockKdfConfigService = mock(); + mockSdkClientFactory = mock(); + mockSdkClientFactory.createSdkClient.mockResolvedValue({ + crypto: () => { + return { + initialize_user_crypto: jest.fn(), + make_keys_for_user_crypto_v2: mockMakeKeysForUserCryptoV2, + get_v2_rotated_account_keys: mockGetV2RotatedAccountKeys, + } as any; + }, + } as BitwardenClient); + mockSecurityStateService = mock(); keyRotationService = new TestUserKeyRotationService( mockApiService, @@ -327,6 +373,8 @@ describe("KeyRotationService", () => { mockConfigService, mockCryptoFunctionService, mockKdfConfigService, + mockSdkClientFactory, + mockSecurityStateService, ); }); @@ -334,13 +382,16 @@ describe("KeyRotationService", () => { jest.clearAllMocks(); jest.mock("@bitwarden/key-management-ui"); jest.spyOn(PureCrypto, "make_user_key_aes256_cbc_hmac").mockReturnValue(new Uint8Array(64)); - jest.spyOn(PureCrypto, "make_user_key_xchacha20_poly1305").mockReturnValue(new Uint8Array(70)); jest .spyOn(PureCrypto, "encrypt_user_key_with_master_password") .mockReturnValue("mockNewUserKey"); + Object.defineProperty(SdkLoadService, "Ready", { + value: Promise.resolve(), + configurable: true, + }); }); - describe("rotateUserKeyAndEncryptedData", () => { + describe("rotateUserKeyMasterPasswordAndEncryptedData", () => { let privateKey: BehaviorSubject; let keyPair: BehaviorSubject<{ privateKey: UserPrivateKey; publicKey: UserPublicKey }>; @@ -438,6 +489,64 @@ describe("KeyRotationService", () => { expect(arg.accountUnlockData.passkeyUnlockData.length).toBe(2); }); + it("passes the EnrollAeadOnKeyRotation feature flag to getRotatedAccountKeysFlagged", async () => { + KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue; + AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted; + EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted; + mockKdfConfigService.getKdfConfig$.mockReturnValue( + new BehaviorSubject(new PBKDF2KdfConfig(100000)), + ); + mockKeyService.userKey$.mockReturnValue( + new BehaviorSubject(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey), + ); + mockKeyService.userEncryptedPrivateKey$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V1 as string as EncryptedString), + ); + mockKeyService.userSigningKey$.mockReturnValue(new BehaviorSubject(null)); + mockSecurityStateService.accountSecurityState$.mockReturnValue(new BehaviorSubject(null)); + mockConfigService.getFeatureFlag.mockResolvedValue(true); + + const spy = jest.spyOn(keyRotationService, "getRotatedAccountKeysFlagged").mockResolvedValue({ + userKey: TEST_VECTOR_USER_KEY_V2, + accountKeysRequest: { + userKeyEncryptedAccountPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + accountPublicKey: TEST_VECTOR_PUBLIC_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: TEST_VECTOR_PUBLIC_KEY_V2, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + }, + signatureKeyPair: { + wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2, + signatureAlgorithm: "ed25519", + }, + securityState: { + securityState: TEST_VECTOR_SECURITY_STATE_V2, + securityVersion: 2, + }, + }, + }); + + await keyRotationService.rotateUserKeyMasterPasswordAndEncryptedData( + "mockMasterPassword", + "mockMasterPassword1", + mockUser, + "masterPasswordHint", + ); + + expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith( + FeatureFlag.EnrollAeadOnKeyRotation, + ); + expect(spy).toHaveBeenCalledWith( + mockUser.id, + expect.any(PBKDF2KdfConfig), + mockUser.email, + expect.objectContaining({ version: 1 }), + true, + ); + }); + it("throws if kdf config is null", async () => { KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue; AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted; @@ -511,17 +620,17 @@ describe("KeyRotationService", () => { }); describe("getNewAccountKeysV1", () => { - const currentUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; - const mockEncryptedPrivateKey = new EncString( - "2.eh465OrUcluL9UpnCOUTAg==|2HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=", - ); - const mockNewEncryptedPrivateKey = new EncString( - "2.ab465OrUcluL9UpnCOUTAg==|4HXNXwrLwAjUfZ/U75c92rZEltt1eHxjMkp/ADAmx346oT1+GaQvaL1QIV/9Om0T72m8AnlO92iUfWdhbA/ifHZ+lhFoUVeyw1M88CMzktbVcq42rFoK7SGHSAGdTL3ccUWKI8yCCQJhpt2X6a/5+T7ey5k2CqvylKyOtkiCnVeLmYqETn5BM9Rl3tEgJW1yDLuSJ+L+Qh9xnk/Z3zJUV5HAs+YwjKwuSNrd00SXjDyx8rBEstD9MKI+lrk7to/q90vqKqCucAj/dzUpVtHe88al2AAlBVwQ13HUPdNFOyti6niUgCAWx+DzRqlhkFvl/z/rtxtQsyqq/3Eh/EL54ylxKzAya0ev9EaIOm/dD1aBmI58p4Bs0eMOCIKJjtw+Cmdql+RhCtKtumgFShqyXv+LfD/FgUsdTVNExk3YNhgwPR4jOaMa/j9LCrBMCLKxdAhQyBe7T3qoX1fBBirvY6t77ifMu1YEQ6DfmFphVSwDH5C9xGeTSh5IELSf0tGVtlWUe9RffDDzccD0L1lR8U+dqzoSTYCuXvhEhQptdIW6fpH/47u0M5MiI97/d35A7Et2I1gjHp7WF3qsY20ellBueu7ZL5P1BmqPXl58yaBBXJaCutYHDfIucspqdZmfBGEbdRT4wmuZRON0J8zLmUejM0VR/2MOmpfyYQXnJhTfrvnZ1bOg1aMhUxJ2vhDNPXUFm5b+vwsho4GEvcLAKq9WwbvOJ/sK7sEVfTfEO2IG+0X6wkWm7RpR6Wq9FGKSrv2PSjMAYnb+z3ETeWiaaiD+tVFxa2AaqsbOuX092/86GySpHES7cFWhQ/YMOgj6egUi8mEC0CqMXYsx0TTJDsn16oP+XB3a2WoRqzE0YBozp2aMXxhVf/jMZ03BmEmRQu5B+Sq1gMEZwtIfJ+srkZLMYlLjvVw92FRoFy+N6ytPiyf6RMHMUnJ3vEZSBogaElYoQAtFJ5kK811CUzb78zEHH8xWtPrCZn9zZfvf/zaWxo7fpV8VwAwUeHXHcQMraZum5QeO+5tLRUYrLm85JNelGfmUA3BjfNyFbfb32PhkWWd0CbDaPME48uIriVK32pNEtvtR/+I/f3YgA/jP9kSlDvbzG/OAg/AFBIpNwKUzsu4+va8mI+O5FDufw5D74WwdGJ9DeyEb2CHtWMR1VwtFKL0ZZsqltNf8EkBeJ5RtTNtAMM8ie4dDZaKC96ymQHKrdB4hjkAr0F1XFsU4XdOa9Nbkdcm/7KoNc6bE6oJtG9lqE8h+1CysfcbfJ7am+hvDFzT0IPmp3GDSMAk+e6xySgFQw0C/SZ7LQsxPa1s6hc+BOtTn0oClZnU7Mowxv+z+xURJj4Yp3Cy6tAoia1jEQSs6lSMNKPf9bi3xFKtPl4143hwhpvTAzJUcski9OVGd7Du+VyxwIrvLqp5Ct/oNrESVJpf1EDCs9xT1EW+PiSkRmHXoZ1t5MOLFEiMAZL2+bNe3A2661oJeMtps8zrfCVc251OUE1WvqWePlTOs5TDVqdwDH88J6rHLsbaf33Mxh5DP8gMfZQxE44Nsp6H0/Szfkss5UmFwBEpHjl1GJMWDnB3u2d+l1CSkLoB6C+diAUlY6wL/VwJBeMPHZTf6amQIS2B/lo/CnvV/E3k=|uuoY4b7xwMYBNIZi85KBsaHmNqtJl5FrKxZI9ugeNwc=", - ); + const currentUserKey = TEST_VECTOR_USER_KEY_V1; + const mockEncryptedPrivateKey = TEST_VECTOR_PRIVATE_KEY_V1 as WrappedPrivateKey; + const mockNewEncryptedPrivateKey = TEST_VECTOR_PRIVATE_KEY_V1_ROTATED as WrappedPrivateKey; beforeAll(() => { mockEncryptService.unwrapDecapsulationKey.mockResolvedValue(new Uint8Array(200)); - mockEncryptService.wrapDecapsulationKey.mockResolvedValue(mockNewEncryptedPrivateKey); - mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue(new Uint8Array(400)); + mockEncryptService.wrapDecapsulationKey.mockResolvedValue( + new EncString(mockNewEncryptedPrivateKey), + ); + mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue( + new Uint8Array(400) as UnsignedPublicKey, + ); }); afterAll(() => { @@ -529,28 +638,110 @@ describe("KeyRotationService", () => { }); it("returns new account keys", async () => { - const result = await keyRotationService.getNewAccountKeysV1( - currentUserKey, - mockEncryptedPrivateKey, - ); + const result = await keyRotationService.getNewAccountKeysV1({ + version: 1, + userKey: currentUserKey, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: mockEncryptedPrivateKey, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey, + }, + }); expect(result).toEqual({ userKey: expect.any(SymmetricCryptoKey), - asymmetricEncryptionKeys: { + publicKeyEncryptionKeyPair: { wrappedPrivateKey: mockNewEncryptedPrivateKey, - publicKey: Utils.fromBufferToB64(new Uint8Array(400)), + publicKey: new Uint8Array(400) as UserPublicKey, }, }); }); }); describe("getNewAccountKeysV2", () => { - it("throws not supported", async () => { - await expect( - keyRotationService.getNewAccountKeysV2( - new SymmetricCryptoKey(new Uint8Array(64)) as UserKey, - null, - ), - ).rejects.toThrow("User encryption v2 upgrade is not supported yet"); + it("rotates a v2 user", async () => { + mockGetV2RotatedAccountKeys.mockReturnValue({ + userKey: TEST_VECTOR_USER_KEY_V2.toBase64(), + privateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: TEST_VECTOR_PUBLIC_KEY_V2, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + signingKey: TEST_VECTOR_SIGNING_KEY_V2, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2, + securityState: TEST_VECTOR_SECURITY_STATE_V2, + securityVersion: 2, + }); + const result = await keyRotationService.getNewAccountKeysV2( + "00000000-0000-0000-0000-000000000000" as UserId, + new PBKDF2KdfConfig(600_000), + "mockuseremail", + { + version: 2 as const, + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + }, + signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + }, + ); + expect(mockGetV2RotatedAccountKeys).toHaveBeenCalled(); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + }, + signatureKeyPair: { + wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey, + }, + securityState: { + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + securityStateVersion: 2, + }, + }); + }); + it("upgrades v1 user to v2 user", async () => { + mockMakeKeysForUserCryptoV2.mockReturnValue({ + userKey: TEST_VECTOR_USER_KEY_V2.toBase64(), + privateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + signingKey: TEST_VECTOR_SIGNING_KEY_V2, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2, + securityState: TEST_VECTOR_SECURITY_STATE_V2, + securityVersion: 2, + }); + const result = await keyRotationService.getNewAccountKeysV2( + "00000000-0000-0000-0000-000000000000" as UserId, + new PBKDF2KdfConfig(600_000), + "mockuseremail", + { + version: 1, + userKey: TEST_VECTOR_USER_KEY_V1, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1 as WrappedPrivateKey, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey, + }, + }, + ); + expect(mockMakeKeysForUserCryptoV2).toHaveBeenCalled(); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2), + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + }, + signatureKeyPair: { + wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey, + }, + securityState: { + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + securityStateVersion: 2, + }, + }); }); }); @@ -560,7 +751,7 @@ describe("KeyRotationService", () => { new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey, ); mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash"); - const newKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + const newKey = TEST_VECTOR_USER_KEY_V1; const userAccount = mockUser; const masterPasswordUnlockData = await keyRotationService.createMasterPasswordUnlockDataRequest(newKey, { @@ -572,13 +763,13 @@ describe("KeyRotationService", () => { expect(masterPasswordUnlockData).toEqual({ masterKeyEncryptedUserKey: "mockNewUserKey", email: "mockEmail", - kdfType: 0, + kdfType: KdfType.PBKDF2_SHA256, kdfIterations: 600_000, masterKeyAuthenticationHash: "mockMasterPasswordHash", masterPasswordHint: "mockMasterPasswordHint", }); expect(PureCrypto.encrypt_user_key_with_master_password).toHaveBeenCalledWith( - new SymmetricCryptoKey(new Uint8Array(64)).toEncoded(), + TEST_VECTOR_USER_KEY_V1.toEncoded(), "mockMasterPassword", userAccount.email, new PBKDF2KdfConfig(600_000).toSdkConfig(), @@ -637,8 +828,8 @@ describe("KeyRotationService", () => { masterKeyKdfConfig: new PBKDF2KdfConfig(600_000), masterPasswordHint: "mockMasterPasswordHint", }, - [new Uint8Array(1)], // emergency access public key - [new Uint8Array(2)], // account recovery public key + [new Uint8Array(1) as UnsignedPublicKey], // emergency access public key + [new Uint8Array(2) as UnsignedPublicKey], // account recovery public key ); expect(accountUnlockDataRequest.passkeyUnlockData).toEqual([ { @@ -758,66 +949,29 @@ describe("KeyRotationService", () => { expect(wasTrustDenied).toBe(true); }); - it("returns trusted keys if all dialogs are accepted", async () => { - KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue; - EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted; - AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted; - mockEmergencyAccessService.getPublicKeys.mockResolvedValue([ - mockGranteeEmergencyAccessWithPublicKey, - ]); - mockResetPasswordService.getPublicKeys.mockResolvedValue([ - mockOrganizationUserResetPasswordEntry, - ]); - const { - wasTrustDenied, - trustedOrganizationPublicKeys: trustedOrgs, - trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers, - } = await keyRotationService.verifyTrust(mockUser); - expect(wasTrustDenied).toBe(false); - expect(trustedEmergencyAccessUsers).toEqual([ - mockGranteeEmergencyAccessWithPublicKey.publicKey, - ]); - expect(trustedOrgs).toEqual([mockOrganizationUserResetPasswordEntry.publicKey]); - }); - }); - - describe("makeNewUserKeyV1", () => { - it("throws if old keys is xchacha20poly1305 key", async () => { - await expect( - keyRotationService.makeNewUserKeyV1(new SymmetricCryptoKey(new Uint8Array(70)) as UserKey), - ).rejects.toThrow( - "User account crypto format is v2, but the feature flag is disabled. User key rotation cannot proceed.", - ); - }); - it("returns new user key", async () => { - const oldKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; - const newKey = await keyRotationService.makeNewUserKeyV1(oldKey); - expect(newKey).toEqual(new SymmetricCryptoKey(new Uint8Array(64))); - }); - }); - - describe("makeNewUserKeyV2", () => { - it("returns xchacha20poly1305 key", async () => { - const oldKey = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey; - const { newUserKey } = await keyRotationService.makeNewUserKeyV2(oldKey); - expect(newUserKey).toEqual(new SymmetricCryptoKey(new Uint8Array(70))); - }); - it("returns isUpgrading true if old key is v1", async () => { - const oldKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; - const newKey = await keyRotationService.makeNewUserKeyV2(oldKey); - expect(newKey).toEqual({ - newUserKey: new SymmetricCryptoKey(new Uint8Array(70)), - isUpgrading: true, - }); - }); - it("returns isUpgrading false if old key is v2", async () => { - const oldKey = new SymmetricCryptoKey(new Uint8Array(70)) as UserKey; - const newKey = await keyRotationService.makeNewUserKeyV2(oldKey); - expect(newKey).toEqual({ - newUserKey: new SymmetricCryptoKey(new Uint8Array(70)), - isUpgrading: false, - }); - }); + test.each([ + [[mockGranteeEmergencyAccessWithPublicKey], []], + [[], [mockOrganizationUserResetPasswordEntry]], + [[], []], + [[mockGranteeEmergencyAccessWithPublicKey], [mockOrganizationUserResetPasswordEntry]], + ])( + "returns trusted keys when dialogs are open and public keys are provided", + async (emUsers, orgs) => { + KeyRotationTrustInfoComponent.open = initialPromptedOpenTrue; + EmergencyAccessTrustComponent.open = emergencyAccessTrustOpenTrusted; + AccountRecoveryTrustComponent.open = accountRecoveryTrustOpenTrusted; + mockEmergencyAccessService.getPublicKeys.mockResolvedValue(emUsers); + mockResetPasswordService.getPublicKeys.mockResolvedValue(orgs); + const { + wasTrustDenied, + trustedOrganizationPublicKeys: trustedOrgs, + trustedEmergencyAccessUserPublicKeys: trustedEmergencyAccessUsers, + } = await keyRotationService.verifyTrust(mockUser); + expect(wasTrustDenied).toBe(false); + expect(trustedEmergencyAccessUsers).toEqual(emUsers.map((e) => e.publicKey)); + expect(trustedOrgs).toEqual(orgs.map((o) => o.publicKey)); + }, + ); }); describe("getAccountDataRequest", () => { @@ -890,13 +1044,264 @@ describe("KeyRotationService", () => { }); describe("isV1UserKey", () => { - const v1Key = new SymmetricCryptoKey(new Uint8Array(64)); - const v2Key = new SymmetricCryptoKey(new Uint8Array(70)); + const aes256CbcHmacV1UserKey = new SymmetricCryptoKey(new Uint8Array(64)); + const coseV2UserKey = new SymmetricCryptoKey(new Uint8Array(70)); it("returns true for v1 key", () => { - expect(keyRotationService.isV1User(v1Key as UserKey)).toBe(true); + expect(keyRotationService.isV1User(aes256CbcHmacV1UserKey as UserKey)).toBe(true); }); it("returns false for v2 key", () => { - expect(keyRotationService.isV1User(v2Key as UserKey)).toBe(false); + expect(keyRotationService.isV1User(coseV2UserKey as UserKey)).toBe(false); + }); + it("returns false for 32 byte AES256-CBC key", () => { + const aes256CbcKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + expect(keyRotationService.isV1User(aes256CbcKey)).toBe(false); + }); + }); + + describe("makeServerMasterKeyAuthenticationHash", () => { + it("returns the master key authentication hash", async () => { + mockKeyService.makeMasterKey.mockResolvedValue( + new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey, + ); + mockKeyService.hashMasterKey.mockResolvedValue("mockMasterPasswordHash"); + const masterKeyAuthenticationHash = + await keyRotationService.makeServerMasterKeyAuthenticationHash( + "mockMasterPassword", + new PBKDF2KdfConfig(600_000), + "mockEmail", + ); + expect(masterKeyAuthenticationHash).toBe("mockMasterPasswordHash"); + expect(mockKeyService.makeMasterKey).toHaveBeenCalledWith( + "mockMasterPassword", + "mockEmail", + new PBKDF2KdfConfig(600_000), + ); + expect(mockKeyService.hashMasterKey).toHaveBeenCalledWith( + "mockMasterPassword", + new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey, + HashPurpose.ServerAuthorization, + ); + }); + }); + + describe("getCryptographicStateForUser", () => { + beforeEach(() => { + mockKdfConfigService.getKdfConfig$.mockReturnValue( + new BehaviorSubject(new PBKDF2KdfConfig(100000)), + ); + mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(TEST_VECTOR_USER_KEY_V2)); + mockKeyService.userEncryptedPrivateKey$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V2 as string as EncryptedString), + ); + mockKeyService.userSigningKey$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey), + ); + mockSecurityStateService.accountSecurityState$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState), + ); + mockCryptoFunctionService.rsaExtractPublicKey.mockResolvedValue( + new Uint8Array(400) as UnsignedPublicKey, + ); + }); + + it("returns the cryptographic state for v1 user", async () => { + mockKeyService.userKey$.mockReturnValue( + new BehaviorSubject(new SymmetricCryptoKey(new Uint8Array(64)) as UserKey), + ); + mockKeyService.userEncryptedPrivateKey$.mockReturnValue( + new BehaviorSubject(TEST_VECTOR_PRIVATE_KEY_V1 as string as EncryptedString), + ); + mockKeyService.userSigningKey$.mockReturnValue(new BehaviorSubject(null)); + mockSecurityStateService.accountSecurityState$.mockReturnValue(new BehaviorSubject(null)); + + const cryptographicState = await keyRotationService.getCryptographicStateForUser(mockUser); + expect(cryptographicState).toEqual({ + masterKeyKdfConfig: new PBKDF2KdfConfig(100000), + masterKeySalt: "mockemail", // the email is lowercased to become the salt + cryptographicStateParameters: { + version: 1, + userKey: TEST_VECTOR_USER_KEY_V1, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1), + }, + }, + }); + }); + + it("returns the cryptographic state for v2 user", async () => { + const cryptographicState = await keyRotationService.getCryptographicStateForUser(mockUser); + expect(cryptographicState).toEqual({ + masterKeyKdfConfig: new PBKDF2KdfConfig(100000), + masterKeySalt: "mockemail", // the email is lowercased to become the salt + cryptographicStateParameters: { + version: 2, + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + }, + signingKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + }, + }); + }); + + it("throws if no kdf config is found", async () => { + mockKdfConfigService.getKdfConfig$.mockReturnValue(new BehaviorSubject(null)); + await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow( + "Failed to get KDF config", + ); + }); + + it("throws if current user key is not found", async () => { + mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(null)); + await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow( + "Failed to get User key", + ); + }); + + it("throws if private key is not found", async () => { + mockKeyService.userEncryptedPrivateKey$.mockReturnValue(new BehaviorSubject(null)); + await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow( + "Failed to get Private key", + ); + }); + + it("throws if user key is not AES256-CBC-HMAC or COSE", async () => { + const invalidKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + mockKeyService.userKey$.mockReturnValue(new BehaviorSubject(invalidKey)); + await expect(keyRotationService.getCryptographicStateForUser(mockUser)).rejects.toThrow( + "Unsupported user key type", + ); + }); + }); + + describe("getRotatedAccountKeysFlagged", () => { + const userId = "mockUserId" as UserId; + const kdfConfig = new PBKDF2KdfConfig(100000); + const masterKeySalt = "mockSalt"; + const v1Params = { + version: 1, + userKey: TEST_VECTOR_USER_KEY_V1, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey, + }, + } as V1CryptographicStateParameters; + const v2Params = { + version: 2, + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + }, + signingKey: TEST_VECTOR_SIGNING_KEY_V2, + securityState: TEST_VECTOR_SECURITY_STATE_V2, + } as V2CryptographicStateParameters; + + beforeEach(() => { + jest.spyOn(keyRotationService, "getNewAccountKeysV1").mockResolvedValue({ + userKey: TEST_VECTOR_USER_KEY_V1, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V1_ROTATED, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V1) as UnsignedPublicKey, + }, + }); + jest.spyOn(keyRotationService, "getNewAccountKeysV2").mockResolvedValue({ + userKey: TEST_VECTOR_USER_KEY_V2, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: TEST_VECTOR_PRIVATE_KEY_V2, + publicKey: Utils.fromB64ToArray(TEST_VECTOR_PUBLIC_KEY_V2) as UnsignedPublicKey, + signedPublicKey: TEST_VECTOR_SIGNED_PUBLIC_KEY_V2, + }, + signatureKeyPair: { + wrappedSigningKey: TEST_VECTOR_SIGNING_KEY_V2 as WrappedSigningKey, + verifyingKey: TEST_VECTOR_VERIFYING_KEY_V2 as VerifyingKey, + }, + securityState: { + securityState: TEST_VECTOR_SECURITY_STATE_V2 as SignedSecurityState, + securityStateVersion: 2, + }, + }); + jest + .spyOn(AccountKeysRequest, "fromV1CryptographicState") + .mockReturnValue("v1Request" as any); + jest + .spyOn(AccountKeysRequest, "fromV2CryptographicState") + .mockResolvedValue("v2Request" as any); + }); + + it("returns v2 keys and request if v2UpgradeEnabled is true", async () => { + const result = await keyRotationService.getRotatedAccountKeysFlagged( + userId, + kdfConfig, + masterKeySalt, + v1Params, + true, + ); + expect(keyRotationService.getNewAccountKeysV2).toHaveBeenCalledWith( + userId, + kdfConfig, + masterKeySalt, + v1Params, + ); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V2, + accountKeysRequest: "v2Request", + }); + }); + + it("returns v2 keys and request if params.version is 2", async () => { + const result = await keyRotationService.getRotatedAccountKeysFlagged( + userId, + kdfConfig, + masterKeySalt, + v2Params, + false, + ); + expect(keyRotationService.getNewAccountKeysV2).toHaveBeenCalledWith( + userId, + kdfConfig, + masterKeySalt, + v2Params, + ); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V2, + accountKeysRequest: "v2Request", + }); + }); + + it("returns v1 keys and request if v2UpgradeEnabled is false and params.version is 1", async () => { + const result = await keyRotationService.getRotatedAccountKeysFlagged( + userId, + kdfConfig, + masterKeySalt, + v1Params, + false, + ); + expect(keyRotationService.getNewAccountKeysV1).toHaveBeenCalledWith(v1Params); + expect(result).toEqual({ + userKey: TEST_VECTOR_USER_KEY_V1, + accountKeysRequest: "v1Request", + }); + }); + }); + + describe("ensureIsAllowedToRotateUserKey", () => { + it("resolves if last sync exists", async () => { + mockSyncService.getLastSync.mockResolvedValue(new Date()); + await expect(keyRotationService.ensureIsAllowedToRotateUserKey()).resolves.toBeUndefined(); + }); + + it("throws if last sync is null", async () => { + mockSyncService.getLastSync.mockResolvedValue(null); + await expect(keyRotationService.ensureIsAllowedToRotateUserKey()).rejects.toThrow( + /de-synced|log out and log back in/i, + ); + expect(mockLogService.info).toHaveBeenCalledWith( + "[Userkey rotation] Client was never synced. Aborting!", + ); }); }); }); diff --git a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts index f7f611b75ee..0980beddd09 100644 --- a/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts +++ b/apps/web/src/app/key-management/key-rotation/user-key-rotation.service.ts @@ -1,5 +1,5 @@ import { Injectable } from "@angular/core"; -import { firstValueFrom } from "rxjs"; +import { firstValueFrom, Observable } from "rxjs"; import { Account } from "@bitwarden/common/auth/abstractions/account.service"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -7,13 +7,21 @@ import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/a import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; -import { firstValueFromOrThrow } from "@bitwarden/common/key-management/utils"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { + SignedSecurityState, + UnsignedPublicKey, + WrappedPrivateKey, + WrappedSigningKey, +} from "@bitwarden/common/key-management/types"; import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout"; 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 { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; +import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { EncryptionType, HashPurpose } from "@bitwarden/common/platform/enums"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { UserId } from "@bitwarden/common/types/guid"; @@ -28,7 +36,7 @@ import { EmergencyAccessTrustComponent, KeyRotationTrustInfoComponent, } from "@bitwarden/key-management-ui"; -import { PureCrypto } from "@bitwarden/sdk-internal"; +import { PureCrypto, TokenProvider } from "@bitwarden/sdk-internal"; import { OrganizationUserResetPasswordService } from "../../admin-console/organizations/members/services/organization-user-reset-password/organization-user-reset-password.service"; import { WebauthnLoginAdminService } from "../../auth/core"; @@ -39,6 +47,11 @@ import { MasterPasswordUnlockDataRequest } from "./request/master-password-unloc import { RotateUserAccountKeysRequest } from "./request/rotate-user-account-keys.request"; import { UnlockDataRequest } from "./request/unlock-data.request"; import { UserDataRequest } from "./request/userdata.request"; +import { V1UserCryptographicState } from "./types/v1-cryptographic-state"; +import { + fromSdkV2KeysToV2UserCryptographicState, + V2UserCryptographicState, +} from "./types/v2-cryptographic-state"; import { UserKeyRotationApiService } from "./user-key-rotation-api.service"; type MasterPasswordAuthenticationAndUnlockData = { @@ -48,6 +61,19 @@ type MasterPasswordAuthenticationAndUnlockData = { masterPasswordHint: string; }; +/** + * A token provider that exposes a null access token to the SDK. + */ +class NoopTokenProvider implements TokenProvider { + constructor() {} + + async get_access_token(): Promise { + // Ignore from the test coverage, since this is called by the SDK + /* istanbul ignore next */ + return undefined; + } +} + @Injectable({ providedIn: "root" }) export class UserKeyRotationService { constructor( @@ -70,6 +96,8 @@ export class UserKeyRotationService { private configService: ConfigService, private cryptoFunctionService: CryptoFunctionService, private kdfConfigService: KdfConfigService, + private sdkClientFactory: SdkClientFactory, + private securityStateService: SecurityStateService, ) {} /** @@ -85,12 +113,15 @@ export class UserKeyRotationService { user: Account, newMasterPasswordHint?: string, ): Promise { - this.logService.info("[UserKey Rotation] Starting user key rotation..."); + // Key-rotation uses the SDK, so we need to ensure that the SDK is loaded / the WASM initialized. + await SdkLoadService.Ready; const upgradeToV2FeatureFlagEnabled = await this.configService.getFeatureFlag( FeatureFlag.EnrollAeadOnKeyRotation, ); + this.logService.info("[UserKey Rotation] Starting user key rotation..."); + // Make sure all conditions match - e.g. account state is up to date await this.ensureIsAllowedToRotateUserKey(); @@ -104,53 +135,26 @@ export class UserKeyRotationService { } // Read current cryptographic state / settings - const masterKeyKdfConfig: KdfConfig = (await firstValueFromOrThrow( - this.kdfConfigService.getKdfConfig$(user.id), - "KDF config", - ))!; - // The masterkey salt used for deriving the masterkey always needs to be trimmed and lowercased. - const masterKeySalt = user.email.trim().toLowerCase(); - const currentUserKey: UserKey = (await firstValueFromOrThrow( - this.keyService.userKey$(user.id), - "User key", - ))!; - const currentUserKeyWrappedPrivateKey = new EncString( - (await firstValueFromOrThrow( - this.keyService.userEncryptedPrivateKey$(user.id), - "User encrypted private key", - ))!, - ); + const { + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters: currentCryptographicStateParameters, + } = await this.getCryptographicStateForUser(user); - // Update account keys - // This creates at least a new user key, and possibly upgrades user encryption formats - let newUserKey: UserKey; - let wrappedPrivateKey: EncString; - let publicKey: string; - if (upgradeToV2FeatureFlagEnabled) { - this.logService.info("[Userkey rotation] Using v2 account keys"); - const { userKey, asymmetricEncryptionKeys } = await this.getNewAccountKeysV2( - currentUserKey, - currentUserKeyWrappedPrivateKey, - ); - newUserKey = userKey; - wrappedPrivateKey = asymmetricEncryptionKeys.wrappedPrivateKey; - publicKey = asymmetricEncryptionKeys.publicKey; - } else { - this.logService.info("[Userkey rotation] Using v1 account keys"); - const { userKey, asymmetricEncryptionKeys } = await this.getNewAccountKeysV1( - currentUserKey, - currentUserKeyWrappedPrivateKey, - ); - newUserKey = userKey; - wrappedPrivateKey = asymmetricEncryptionKeys.wrappedPrivateKey; - publicKey = asymmetricEncryptionKeys.publicKey; - } + // Get new set of keys for the account. + const { userKey: newUserKey, accountKeysRequest } = await this.getRotatedAccountKeysFlagged( + user.id, + masterKeyKdfConfig, + user.email, + currentCryptographicStateParameters, + upgradeToV2FeatureFlagEnabled, + ); // Assemble the key rotation request const request = new RotateUserAccountKeysRequest( await this.getAccountUnlockDataRequest( user.id, - currentUserKey, + currentCryptographicStateParameters.userKey, newUserKey, { masterPassword: newMasterPassword, @@ -161,8 +165,12 @@ export class UserKeyRotationService { trustedEmergencyAccessUserPublicKeys, trustedOrganizationPublicKeys, ), - new AccountKeysRequest(wrappedPrivateKey.encryptedString!, publicKey), - await this.getAccountDataRequest(currentUserKey, newUserKey, user), + accountKeysRequest, + await this.getAccountDataRequest( + currentCryptographicStateParameters.userKey, + newUserKey, + user, + ), await this.makeServerMasterKeyAuthenticationHash( currentMasterPassword, masterKeyKdfConfig, @@ -194,55 +202,153 @@ export class UserKeyRotationService { } } + async getRotatedAccountKeysFlagged( + userId: UserId, + kdfConfig: KdfConfig, + masterKeySalt: string, + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters, + v2UpgradeEnabled: boolean, + ): Promise<{ userKey: UserKey; accountKeysRequest: AccountKeysRequest }> { + if (v2UpgradeEnabled || cryptographicStateParameters.version === 2) { + const keys = await this.getNewAccountKeysV2( + userId, + kdfConfig, + masterKeySalt, + cryptographicStateParameters, + ); + return { + userKey: keys.userKey, + accountKeysRequest: await AccountKeysRequest.fromV2CryptographicState(keys), + }; + } else { + const keys = await this.getNewAccountKeysV1( + cryptographicStateParameters as V1CryptographicStateParameters, + ); + return { + userKey: keys.userKey, + accountKeysRequest: AccountKeysRequest.fromV1CryptographicState(keys), + }; + } + } + + /** + * This method rotates the user key of a V1 user and re-encrypts the private key. + * @deprecated Removed after roll-out of V2 encryption. + */ protected async getNewAccountKeysV1( - currentUserKey: UserKey, - currentUserKeyWrappedPrivateKey: EncString, - ): Promise<{ - userKey: UserKey; - asymmetricEncryptionKeys: { - wrappedPrivateKey: EncString; - publicKey: string; - }; - }> { - // Account key rotation creates a new userkey. All downstream data and keys need to be re-encrypted under this key. + cryptographicStateParameters: V1CryptographicStateParameters, + ): Promise { + // Account key rotation creates a new user key. All downstream data and keys need to be re-encrypted under this key. // Further, this method is used to create new keys in the event that the key hierarchy changes, such as for the // creation of a new signing key pair. - const newUserKey = await this.makeNewUserKeyV1(currentUserKey); + const newUserKey = new SymmetricCryptoKey( + PureCrypto.make_user_key_aes256_cbc_hmac(), + ) as UserKey; // Re-encrypt the private key with the new user key // Rotation of the private key is not supported yet const privateKey = await this.encryptService.unwrapDecapsulationKey( - currentUserKeyWrappedPrivateKey, - currentUserKey, + new EncString(cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey), + cryptographicStateParameters.userKey, ); - const newUserKeyWrappedPrivateKey = await this.encryptService.wrapDecapsulationKey( + const newUserKeyWrappedPrivateKey = ( + await this.encryptService.wrapDecapsulationKey(privateKey, newUserKey) + ).encryptedString! as string as WrappedPrivateKey; + const publicKey = (await this.cryptoFunctionService.rsaExtractPublicKey( privateKey, - newUserKey, - ); - const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey); + )) as UnsignedPublicKey; return { userKey: newUserKey, - asymmetricEncryptionKeys: { + publicKeyEncryptionKeyPair: { wrappedPrivateKey: newUserKeyWrappedPrivateKey, - publicKey: Utils.fromBufferToB64(publicKey), + publicKey: publicKey, }, }; } + /** + * This method either enrolls a user from v1 encryption to v2 encryption, rotating the user key, or rotates the keys of a v2 user, staying on v2. + */ protected async getNewAccountKeysV2( - currentUserKey: UserKey, - currentUserKeyWrappedPrivateKey: EncString, - ): Promise<{ - userKey: UserKey; - asymmetricEncryptionKeys: { - wrappedPrivateKey: EncString; - publicKey: string; - }; - }> { - throw new Error("User encryption v2 upgrade is not supported yet"); + userId: UserId, + masterKeyKdfConfig: KdfConfig, + masterKeySalt: string, + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters, + ): Promise { + if (cryptographicStateParameters.version === 1) { + return this.upgradeV1UserToV2UserAccountKeys( + userId, + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters as V1CryptographicStateParameters, + ); + } else { + return this.rotateV2UserAccountKeys( + userId, + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters as V2CryptographicStateParameters, + ); + } } + /** + * Upgrades a V1 user to a V2 user by creating a new user key, re-encrypting the private key, generating a signature key-pair, and + * finally creating a signed security state. + */ + protected async upgradeV1UserToV2UserAccountKeys( + userId: UserId, + kdfConfig: KdfConfig, + email: string, + cryptographicStateParameters: V1CryptographicStateParameters, + ): Promise { + // Initialize an SDK with the current cryptographic state + const sdk = await this.sdkClientFactory.createSdkClient(new NoopTokenProvider()); + await sdk.crypto().initialize_user_crypto({ + userId: asUuid(userId), + kdfParams: kdfConfig.toSdkConfig(), + email: email, + privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey, + signingKey: undefined, + securityState: undefined, + method: { + decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() }, + }, + }); + + return fromSdkV2KeysToV2UserCryptographicState(sdk.crypto().make_keys_for_user_crypto_v2()); + } + + /** + * Generates a new user key for a v2 user, and re-encrypts the private key, signing key. + */ + protected async rotateV2UserAccountKeys( + userId: UserId, + kdfConfig: KdfConfig, + email: string, + cryptographicStateParameters: V2CryptographicStateParameters, + ): Promise { + // Initialize an SDK with the current cryptographic state + const sdk = await this.sdkClientFactory.createSdkClient(new NoopTokenProvider()); + await sdk.crypto().initialize_user_crypto({ + userId: asUuid(userId), + kdfParams: kdfConfig.toSdkConfig(), + email: email, + privateKey: cryptographicStateParameters.publicKeyEncryptionKeyPair.wrappedPrivateKey, + signingKey: cryptographicStateParameters.signingKey, + securityState: cryptographicStateParameters.securityState, + method: { + decryptedKey: { decrypted_user_key: cryptographicStateParameters.userKey.toBase64() }, + }, + }); + + return fromSdkV2KeysToV2UserCryptographicState(sdk.crypto().get_v2_rotated_account_keys()); + } + + /** + * Generates a new request for updating the master-password unlock/authentication data. + */ protected async createMasterPasswordUnlockDataRequest( userKey: UserKey, newUnlockData: MasterPasswordAuthenticationAndUnlockData, @@ -272,13 +378,17 @@ export class UserKeyRotationService { ); } + /** + * Re-generates the accounts unlock methods, including master-password, passkey, trusted device, emergency access, and organization account recovery + * for the new user key. + */ protected async getAccountUnlockDataRequest( userId: UserId, currentUserKey: UserKey, newUserKey: UserKey, masterPasswordAuthenticationAndUnlockData: MasterPasswordAuthenticationAndUnlockData, - trustedEmergencyAccessGranteesPublicKeys: Uint8Array[], - trustedOrganizationPublicKeys: Uint8Array[], + trustedEmergencyAccessGranteesPublicKeys: UnsignedPublicKey[], + trustedOrganizationPublicKeys: UnsignedPublicKey[], ): Promise { // To ensure access; all unlock methods need to be updated and provided the new user key. // User unlock methods @@ -321,10 +431,13 @@ export class UserKeyRotationService { ); } + /** + * Verifies the trust of the organizations and emergency access users by prompting the user. Denying any of these will return early. + */ protected async verifyTrust(user: Account): Promise<{ wasTrustDenied: boolean; - trustedOrganizationPublicKeys: Uint8Array[]; - trustedEmergencyAccessUserPublicKeys: Uint8Array[]; + trustedOrganizationPublicKeys: UnsignedPublicKey[]; + trustedEmergencyAccessUserPublicKeys: UnsignedPublicKey[]; }> { // Since currently the joined organizations and emergency access grantees are // not signed, manual trust prompts are required, to verify that the server @@ -392,11 +505,16 @@ export class UserKeyRotationService { ); return { wasTrustDenied: false, - trustedOrganizationPublicKeys: organizations.map((d) => d.publicKey), - trustedEmergencyAccessUserPublicKeys: emergencyAccessGrantees.map((d) => d.publicKey), + trustedOrganizationPublicKeys: organizations.map((d) => d.publicKey as UnsignedPublicKey), + trustedEmergencyAccessUserPublicKeys: emergencyAccessGrantees.map( + (d) => d.publicKey as UnsignedPublicKey, + ), }; } + /** + * Re-encrypts the account data owned by the user, such as ciphers, folders, and sends with the new user key. + */ protected async getAccountDataRequest( originalUserKey: UserKey, newUnencryptedUserKey: UserKey, @@ -429,64 +547,6 @@ export class UserKeyRotationService { return new UserDataRequest(rotatedCiphers, rotatedFolders, rotatedSends); } - protected async makeNewUserKeyV1(oldUserKey: UserKey): Promise { - // The user's account format is determined by the user key. - // Being tied to the userkey ensures an all-or-nothing approach. A compromised - // server cannot downgrade to a previous format (no signing keys) without - // completely making the account unusable. - // - // V0: AES256-CBC (no userkey, directly using masterkey) (pre-2019 accounts) - // This format is unsupported, and not secure; It is being forced migrated, and being removed - // V1: AES256-CBC-HMAC userkey, no signing key (2019-2025) - // This format is still supported, but may be migrated in the future - // V2: XChaCha20-Poly1305 userkey, signing key, account security version - // This is the new, modern format. - if (this.isV1User(oldUserKey)) { - this.logService.info( - "[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; not upgrading", - ); - return new SymmetricCryptoKey(PureCrypto.make_user_key_aes256_cbc_hmac()) as UserKey; - } else { - // If the feature flag is rolled back, we want to block rotation in order to be as safe as possible with the user's account. - this.logService.info( - "[Userkey rotation] Existing userkey key is XChaCha20-Poly1305, but feature flag is not enabled; aborting..", - ); - throw new Error( - "User account crypto format is v2, but the feature flag is disabled. User key rotation cannot proceed.", - ); - } - } - - protected async makeNewUserKeyV2( - oldUserKey: UserKey, - ): Promise<{ isUpgrading: boolean; newUserKey: UserKey }> { - // The user's account format is determined by the user key. - // Being tied to the userkey ensures an all-or-nothing approach. A compromised - // server cannot downgrade to a previous format (no signing keys) without - // completely making the account unusable. - // - // V0: AES256-CBC (no userkey, directly using masterkey) (pre-2019 accounts) - // This format is unsupported, and not secure; It is being forced migrated, and being removed - // V1: AES256-CBC-HMAC userkey, no signing key (2019-2025) - // This format is still supported, but may be migrated in the future - // V2: XChaCha20-Poly1305 userkey, signing key, account security version - // This is the new, modern format. - const newUserKey: UserKey = new SymmetricCryptoKey( - PureCrypto.make_user_key_xchacha20_poly1305(), - ) as UserKey; - const isUpgrading = this.isV1User(oldUserKey); - if (isUpgrading) { - this.logService.info( - "[Userkey rotation] Existing userkey key is AES256-CBC-HMAC; upgrading to XChaCha20-Poly1305", - ); - } else { - this.logService.info( - "[Userkey rotation] Existing userkey key is XChaCha20-Poly1305; no upgrade needed", - ); - } - return { isUpgrading, newUserKey }; - } - /** * A V1 user has no signing key, and uses AES256-CBC-HMAC. * A V2 user has a signing key, and uses XChaCha20-Poly1305. @@ -516,4 +576,111 @@ export class UserKeyRotationService { HashPurpose.ServerAuthorization, ); } + + /** + * Gets the cryptographic state for a user. This can be a V1 user or a V2 user. + */ + protected async getCryptographicStateForUser(user: Account): Promise<{ + masterKeyKdfConfig: KdfConfig; + masterKeySalt: string; + cryptographicStateParameters: V1CryptographicStateParameters | V2CryptographicStateParameters; + }> { + // Master password unlock + const masterKeyKdfConfig: KdfConfig = (await this.firstValueFromOrThrow( + this.kdfConfigService.getKdfConfig$(user.id), + "KDF config", + ))!; + // The master key salt used for deriving the masterkey always needs to be trimmed and lowercased. + const masterKeySalt = user.email.trim().toLowerCase(); + + // V1 and V2 users both have a user key and a private key + const currentUserKey: UserKey = (await this.firstValueFromOrThrow( + this.keyService.userKey$(user.id), + "User key", + ))!; + const currentUserKeyWrappedPrivateKey: WrappedPrivateKey = new EncString( + (await this.firstValueFromOrThrow( + this.keyService.userEncryptedPrivateKey$(user.id), + "Private key", + ))!, + ).encryptedString! as string as WrappedPrivateKey; + const publicKey = (await this.cryptoFunctionService.rsaExtractPublicKey( + await this.encryptService.unwrapDecapsulationKey( + new EncString(currentUserKeyWrappedPrivateKey), + currentUserKey, + ), + )) as UnsignedPublicKey; + + if (this.isV1User(currentUserKey)) { + return { + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters: { + version: 1, + userKey: currentUserKey, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: currentUserKeyWrappedPrivateKey, + publicKey: publicKey, + }, + }, + }; + } else if (currentUserKey.inner().type === EncryptionType.CoseEncrypt0) { + const signingKey = await this.firstValueFromOrThrow( + this.keyService.userSigningKey$(user.id), + "User signing key", + ); + const securityState = await this.firstValueFromOrThrow( + this.securityStateService.accountSecurityState$(user.id), + "User security state", + ); + + return { + masterKeyKdfConfig, + masterKeySalt, + cryptographicStateParameters: { + version: 2, + userKey: currentUserKey, + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: currentUserKeyWrappedPrivateKey, + publicKey: publicKey, + }, + signingKey: signingKey!, + securityState: securityState!, + }, + }; + } + + /// AES-CBC (no-hmac) keys are not supported as user keys + throw new Error( + `Unsupported user key type: ${currentUserKey.inner().type}. Expected AesCbc256_HmacSha256_B64 or XChaCha20_Poly1305_B64.`, + ); + } + + async firstValueFromOrThrow(value: Observable, name: string): Promise { + const result = await firstValueFrom(value); + if (result == null) { + throw new Error(`Failed to get ${name}`); + } + return result as T; + } } + +export type V1CryptographicStateParameters = { + version: 1; + userKey: UserKey; + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: UnsignedPublicKey; + }; +}; + +export type V2CryptographicStateParameters = { + version: 2; + userKey: UserKey; + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: WrappedPrivateKey; + publicKey: UnsignedPublicKey; + }; + signingKey: WrappedSigningKey; + securityState: SignedSecurityState; +}; diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html index f8ebfa60451..d39156ef4a2 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html @@ -1,19 +1,25 @@
- - - - -
1) { + + + + } + + @if (shouldShowPremiumUpgradeButton$ | async) { + + } + + @let moreProducts = moreProducts$ | async; + @if (moreProducts && moreProducts.length > 0) { +
{{ "moreFromBitwarden" | i18n }}
@@ -57,5 +63,5 @@
- + }
diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts index d1b82bc114d..38e7d12f278 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.spec.ts @@ -1,3 +1,4 @@ +import { Component } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { By } from "@angular/platform-browser"; import { ActivatedRoute, RouterModule } from "@angular/router"; @@ -15,6 +16,13 @@ import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-s import { NavigationProductSwitcherComponent } from "./navigation-switcher.component"; +@Component({ + selector: "app-upgrade-nav-button", + template: "
Upgrade Nav Button
", + standalone: true, +}) +class MockUpgradeNavButtonComponent {} + Object.defineProperty(window, "matchMedia", { writable: true, value: jest.fn().mockImplementation((query) => ({ @@ -41,13 +49,16 @@ describe("NavigationProductSwitcherComponent", () => { other: [], }); + const mockShouldShowPremiumUpgradeButton$ = new BehaviorSubject(false); + beforeEach(async () => { productSwitcherService = mock(); productSwitcherService.products$ = mockProducts$; + productSwitcherService.shouldShowPremiumUpgradeButton$ = mockShouldShowPremiumUpgradeButton$; mockProducts$.next({ bento: [], other: [] }); await TestBed.configureTestingModule({ - imports: [RouterModule, NavigationModule, IconButtonModule], + imports: [RouterModule, NavigationModule, IconButtonModule, MockUpgradeNavButtonComponent], declarations: [NavigationProductSwitcherComponent, I18nPipe], providers: [ { provide: ProductSwitcherService, useValue: productSwitcherService }, @@ -187,15 +198,23 @@ describe("NavigationProductSwitcherComponent", () => { }, isActive: true, }, + { + name: "Test Product", + icon: "bwi-lock", + marketingRoute: { + route: "https://www.example.com/", + external: true, + }, + }, ], other: [], }); fixture.detectChanges(); - const navItem = fixture.debugElement.query(By.directive(NavItemComponent)); + const navItem = fixture.debugElement.queryAll(By.directive(NavItemComponent)); - expect(navItem.componentInstance.forceActiveStyles()).toBe(true); + expect(navItem[0].componentInstance.forceActiveStyles()).toBe(true); }); }); @@ -218,18 +237,56 @@ describe("NavigationProductSwitcherComponent", () => { expect(links[0].textContent).toContain("Password Manager"); expect(links[1].textContent).toContain("Secret Manager"); }); + + it("does not show products list when there is only one item", () => { + mockProducts$.next({ + bento: [{ isActive: true, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }], + other: [], + }); + + fixture.detectChanges(); + + const navItems = fixture.debugElement.queryAll(By.directive(NavItemComponent)); + + expect(navItems.length).toBe(0); + }); }); it("links to `appRoute`", () => { mockProducts$.next({ - bento: [{ isActive: false, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }], + bento: [ + { isActive: true, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }, + { isActive: false, name: "Secret Manager", icon: "bwi-lock", appRoute: "/sm" }, + ], other: [], }); fixture.detectChanges(); - const link = fixture.nativeElement.querySelector("a"); + const links = fixture.nativeElement.querySelectorAll("a"); - expect(link.getAttribute("href")).toBe("/vault"); + expect(links[0].getAttribute("href")).toBe("/vault"); + }); + + describe("upgrade nav button", () => { + it("shows upgrade nav button when shouldShowPremiumUpgradeButton$ is true", () => { + mockShouldShowPremiumUpgradeButton$.next(true); + mockProducts$.next({ + bento: [], + other: [ + { + name: "Organizations", + icon: "bwi-lock", + marketingRoute: { route: "https://www.example.com/", external: true }, + }, + ], + }); + + fixture.detectChanges(); + + const upgradeButton = fixture.nativeElement.querySelector("app-upgrade-nav-button"); + + expect(upgradeButton).toBeTruthy(); + }); }); }); diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.ts index 9d4250087af..8a02fdd7647 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.ts @@ -11,6 +11,9 @@ import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-s export class NavigationProductSwitcherComponent { constructor(private productSwitcherService: ProductSwitcherService) {} + protected readonly shouldShowPremiumUpgradeButton$: Observable = + this.productSwitcherService.shouldShowPremiumUpgradeButton$; + protected readonly accessibleProducts$: Observable = this.productSwitcherService.products$.pipe(map((products) => products.bento ?? [])); diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts index b5f10f9158e..fe2821e3d2c 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts @@ -9,6 +9,9 @@ import { ProviderService } from "@bitwarden/common/admin-console/abstractions/pr import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { FeatureFlag, FeatureFlagValueType } from "@bitwarden/common/enums/feature-flag.enum"; +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 { SyncService } from "@bitwarden/common/platform/sync"; @@ -78,6 +81,18 @@ class MockPlatformUtilsService implements Partial { } } +class MockBillingAccountProfileStateService implements Partial { + hasPremiumFromAnySource$(userId: UserId): Observable { + return of(false); + } +} + +class MockConfigService implements Partial { + getFeatureFlag$(key: Flag): Observable> { + return of(false); + } +} + @Component({ selector: "story-layout", template: ``, @@ -117,6 +132,11 @@ export default { { provide: ProviderService, useClass: MockProviderService }, { provide: SyncService, useClass: MockSyncService }, { provide: PlatformUtilsService, useClass: MockPlatformUtilsService }, + { + provide: BillingAccountProfileStateService, + useClass: MockBillingAccountProfileStateService, + }, + { provide: ConfigService, useClass: MockConfigService }, ProductSwitcherService, { provide: I18nService, diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.module.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.module.ts index b78b1ce6b96..1d0353105c6 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.module.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.module.ts @@ -5,6 +5,7 @@ import { RouterModule } from "@angular/router"; import { NavigationModule } from "@bitwarden/components"; import { I18nPipe } from "@bitwarden/ui-common"; +import { UpgradeNavButtonComponent } from "../../billing/individual/upgrade/upgrade-nav-button/upgrade-nav-button/upgrade-nav-button.component"; import { SharedModule } from "../../shared"; import { NavigationProductSwitcherComponent } from "./navigation-switcher/navigation-switcher.component"; @@ -12,7 +13,14 @@ import { ProductSwitcherContentComponent } from "./product-switcher-content.comp import { ProductSwitcherComponent } from "./product-switcher.component"; @NgModule({ - imports: [SharedModule, A11yModule, RouterModule, NavigationModule, I18nPipe], + imports: [ + SharedModule, + A11yModule, + RouterModule, + NavigationModule, + I18nPipe, + UpgradeNavButtonComponent, + ], declarations: [ ProductSwitcherComponent, ProductSwitcherContentComponent, diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts index 18cb8e26c70..66b6a6fb3cf 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts @@ -10,6 +10,9 @@ import { ProviderService } from "@bitwarden/common/admin-console/abstractions/pr import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { FeatureFlag, FeatureFlagValueType } from "@bitwarden/common/enums/feature-flag.enum"; +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 { SyncService } from "@bitwarden/common/platform/sync"; @@ -78,6 +81,18 @@ class MockPlatformUtilsService implements Partial { } } +class MockBillingAccountProfileStateService implements Partial { + hasPremiumFromAnySource$(userId: UserId): Observable { + return of(false); + } +} + +class MockConfigService implements Partial { + getFeatureFlag$(key: Flag): Observable> { + return of(false); + } +} + @Component({ selector: "story-layout", template: ``, @@ -114,6 +129,11 @@ export default { MockProviderService, { provide: SyncService, useClass: MockSyncService }, { provide: PlatformUtilsService, useClass: MockPlatformUtilsService }, + { + provide: BillingAccountProfileStateService, + useClass: MockBillingAccountProfileStateService, + }, + { provide: ConfigService, useClass: MockConfigService }, MockPlatformUtilsService, ProductSwitcherService, { diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts index efbeb786f77..f7f319f2fab 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts @@ -11,6 +11,8 @@ import { ProviderService } from "@bitwarden/common/admin-console/abstractions/pr import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +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"; @@ -27,6 +29,8 @@ describe("ProductSwitcherService", () => { let providerService: MockProxy; let accountService: FakeAccountService; let platformUtilsService: MockProxy; + let billingAccountProfileStateService: MockProxy; + let configService: MockProxy; let activeRouteParams = convertToParamMap({ organizationId: "1234" }); let singleOrgPolicyEnabled = false; const getLastSync = jest.fn().mockResolvedValue(new Date("2024-05-14")); @@ -48,6 +52,8 @@ describe("ProductSwitcherService", () => { providerService = mock(); accountService = mockAccountServiceWith(userId); platformUtilsService = mock(); + billingAccountProfileStateService = mock(); + configService = mock(); router.url = "/"; router.events = of({}); @@ -85,6 +91,8 @@ describe("ProductSwitcherService", () => { policyAppliesToUser$: () => of(singleOrgPolicyEnabled), }, }, + { provide: BillingAccountProfileStateService, useValue: billingAccountProfileStateService }, + { provide: ConfigService, useValue: configService }, ], }); }); @@ -325,4 +333,57 @@ describe("ProductSwitcherService", () => { expect(appRoute).toEqual(["/organizations", "111-22-33"]); }); + + describe("shouldShowPremiumUpgradeButton$", () => { + it("returns false when feature flag is disabled", async () => { + configService.getFeatureFlag$.mockReturnValue(of(false)); + billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + + initiateService(); + + const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$); + + expect(shouldShow).toBe(false); + }); + + it("returns false when there is no active account", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + accountService.activeAccount$ = of(null); + billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + + initiateService(); + + const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$); + + expect(shouldShow).toBe(false); + }); + + it("returns true when feature flag is enabled, account exists, and user has no premium", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + + initiateService(); + + const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$); + + expect(shouldShow).toBe(true); + expect(billingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith( + userId, + ); + }); + + it("returns false when feature flag is enabled, account exists, but user has premium", async () => { + configService.getFeatureFlag$.mockReturnValue(of(true)); + billingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(true)); + + initiateService(); + + const shouldShow = await firstValueFrom(service.shouldShowPremiumUpgradeButton$); + + expect(shouldShow).toBe(false); + expect(billingAccountProfileStateService.hasPremiumFromAnySource$).toHaveBeenCalledWith( + userId, + ); + }); + }); }); diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts index 95acf4447e9..6cfecd59403 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.ts @@ -2,7 +2,16 @@ // @ts-strict-ignore import { Injectable } from "@angular/core"; import { ActivatedRoute, NavigationEnd, NavigationStart, ParamMap, Router } from "@angular/router"; -import { combineLatest, filter, map, Observable, ReplaySubject, startWith, switchMap } from "rxjs"; +import { + combineLatest, + filter, + map, + Observable, + of, + ReplaySubject, + startWith, + switchMap, +} from "rxjs"; import { canAccessOrgAdmin, @@ -15,6 +24,9 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { Provider } from "@bitwarden/common/admin-console/models/domain/provider"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +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 { SyncService } from "@bitwarden/common/platform/sync"; @@ -99,6 +111,8 @@ export class ProductSwitcherService { private platformUtilsService: PlatformUtilsService, private policyService: PolicyService, private i18nService: I18nService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private configService: ConfigService, ) { this.pollUntilSynced(); } @@ -118,6 +132,20 @@ export class ProductSwitcherService { switchMap((userId) => this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId)), ); + shouldShowPremiumUpgradeButton$: Observable = combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton), + this.accountService.activeAccount$, + ]).pipe( + switchMap(([featureFlag, account]) => { + if (!featureFlag || !account) { + return of(false); + } + return this.billingAccountProfileStateService + .hasPremiumFromAnySource$(account.id) + .pipe(map((hasPremium) => !hasPremium)); + }), + ); + products$: Observable<{ bento: ProductSwitcherItem[]; other: ProductSwitcherItem[]; diff --git a/apps/web/src/app/settings/preferences.component.html b/apps/web/src/app/settings/preferences.component.html index 050d7395caf..40f2f596a13 100644 --- a/apps/web/src/app/settings/preferences.component.html +++ b/apps/web/src/app/settings/preferences.component.html @@ -2,7 +2,7 @@

{{ "preferencesDesc" | i18n }}

-
+ {{ diff --git a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html index 2074f380e32..a897c8c4c2c 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html +++ b/apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html @@ -104,7 +104,7 @@ label="{{ 'options' | i18n }}" > - + + @if (showArchiveButton) { + + } + + @if (showUnArchiveButton) { + + } + + + + + +
- {{ "onceYouReviewApplications" | i18n }} + {{ "onceYouReviewApps" | i18n }}
} diff --git a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts index b9cab679560..796cf212a67 100644 --- a/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts +++ b/bitwarden_license/bit-web/src/app/dirt/reports/member-access-report/member-access-report.component.ts @@ -9,6 +9,7 @@ import { BehaviorSubject, debounceTime, firstValueFrom, lastValueFrom } from "rx import { UserNamePipe } from "@bitwarden/angular/pipes/user-name.pipe"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { OrganizationId } from "@bitwarden/common/types/guid"; @@ -56,6 +57,7 @@ export class MemberAccessReportComponent implements OnInit { protected dialogService: DialogService, protected userNamePipe: UserNamePipe, protected billingApiService: BillingApiServiceAbstraction, + protected organizationMetadataService: OrganizationMetadataServiceAbstraction, ) { // Connect the search input to the table dataSource filter input this.searchControl.valueChanges @@ -69,8 +71,8 @@ export class MemberAccessReportComponent implements OnInit { const params = await firstValueFrom(this.route.params); this.organizationId = params.organizationId; - const billingMetadata = await this.billingApiService.getOrganizationBillingMetadata( - this.organizationId, + const billingMetadata = await firstValueFrom( + this.organizationMetadataService.getOrganizationMetadata$(this.organizationId), ); this.orgIsOnSecretsManagerStandalone = billingMetadata.isOnSecretsManagerStandalone; diff --git a/bitwarden_license/bit-web/webpack.config.js b/bitwarden_license/bit-web/webpack.config.js index 37e0a0c5e03..6ac1efdc192 100644 --- a/bitwarden_license/bit-web/webpack.config.js +++ b/bitwarden_license/bit-web/webpack.config.js @@ -1,10 +1,31 @@ -const { buildConfig } = require("../../apps/web/webpack.base"); +const path = require("path"); +const { buildConfig } = require(path.resolve(__dirname, "../../apps/web/webpack.base")); -module.exports = buildConfig({ - configName: "Commercial", - app: { - entry: "../../bitwarden_license/bit-web/src/main.ts", - entryModule: "../../bitwarden_license/bit-web/src/app/app.module#AppModule", - }, - tsConfig: "../../bitwarden_license/bit-web/tsconfig.build.json", -}); +module.exports = (webpackConfig, context) => { + const isNxBuild = context && context.options; + if (isNxBuild) { + return buildConfig({ + configName: "Commercial", + app: { + entry: context.options.main + ? path.resolve(context.context.root, context.options.main) + : path.resolve(__dirname, "src/main.ts"), + entryModule: "bitwarden_license/bit-web/src/app/app.module#AppModule", + }, + tsConfig: "bitwarden_license/bit-web/tsconfig.build.json", + outputPath: + context.context && context.context.root + ? path.resolve(context.context.root, context.options.outputPath) + : context.options.outputPath, + }); + } else { + return buildConfig({ + configName: "Commercial", + app: { + entry: path.resolve(__dirname, "src/main.ts"), + entryModule: "bitwarden_license/bit-web/src/app/app.module#AppModule", + }, + tsConfig: path.resolve(__dirname, "tsconfig.build.json"), + }); + } +}; diff --git a/docs/using-nx-to-build-projects.md b/docs/using-nx-to-build-projects.md index f1fd54e1c20..408a6e92fb1 100644 --- a/docs/using-nx-to-build-projects.md +++ b/docs/using-nx-to-build-projects.md @@ -2,7 +2,7 @@ Bitwarden uses [Nx](https://nx.dev/) to make building projects from the monorepo easier. To build, lint, or test a project you'll want to reference the project's `project.json` file for availible commands and their names. Then you'll run `npx nx [your_command] [your_project] [your_options]`. Run `npx nx --help` to see availible options, there are many. -Please note: the Nx implementation is a work in progress. Not all apps support Nx yet, CI still uses the old npm builds, and we have many "legacy" libraries that use hacks to get them into the Nx project graph. +Please note: the Nx implementation is a work in progress. CI still uses the old npm builds, and we have many "legacy" libraries that use hacks to get them into the Nx project graph. ## Quick Start @@ -11,6 +11,7 @@ Please note: the Nx implementation is a work in progress. Not all apps support N ```bash # Build a project npx nx build cli +npx nx build-native desktop # Some apps have special build commands npx nx build state # Modern libs and apps have simple, all lowercase target names npx nx build @bitwarden/common # Legacy libs have a special naming convention and include the @bitwarden prefix @@ -203,6 +204,6 @@ npx nx reset ## Additional Resources -- [Nx Documentation](https://nx.dev/getting-started/intro) -- [Nx CLI Reference](https://nx.dev/packages/nx/documents/cli) +- [Nx Intro Documentation](https://nx.dev/getting-started/intro) +- [Nx CLI Commands Reference](https://nx.dev/docs/reference/nx-commands) - [Nx Workspace Configuration](https://nx.dev/reference/project-configuration) diff --git a/libs/angular/src/scss/bwicons/styles/style.scss b/libs/angular/src/scss/bwicons/styles/style.scss index 755088a92a0..93f5856e3df 100644 --- a/libs/angular/src/scss/bwicons/styles/style.scss +++ b/libs/angular/src/scss/bwicons/styles/style.scss @@ -100,6 +100,7 @@ $icomoon-font-path: "~@bitwarden/angular/src/scss/bwicons/fonts/" !default; } // For new icons - add their glyph name and value to the map below +// Also add to `libs/components/src/shared/icon.ts` $icons: ( "angle-down": "\e900", "angle-left": "\e901", diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 15f52d0e65c..53da6e9fd8e 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -145,12 +145,14 @@ import { } from "@bitwarden/common/billing/abstractions"; import { AccountBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/account/account-billing-api.service.abstraction"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction"; import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction"; import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction"; import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service"; import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service"; import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service"; import { OrganizationBillingApiService } from "@bitwarden/common/billing/services/organization/organization-billing-api.service"; +import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service"; import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service"; import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service"; import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service"; @@ -170,13 +172,19 @@ import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/ch import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction"; import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service"; import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; +import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction"; +import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { DefaultMasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/services/default-master-password-unlock.service"; import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; +import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service"; import { SendPasswordService, DefaultSendPasswordService, @@ -702,6 +710,11 @@ const safeProviders: SafeProvider[] = [ KdfConfigService, ], }), + safeProvider({ + provide: SecurityStateService, + useClass: DefaultSecurityStateService, + deps: [StateProvider], + }), safeProvider({ provide: RestrictedItemTypesService, useClass: RestrictedItemTypesService, @@ -780,6 +793,7 @@ const safeProviders: SafeProvider[] = [ provide: InternalSendService, useClass: SendService, deps: [ + AccountServiceAbstraction, KeyService, I18nServiceAbstraction, KeyGenerationService, @@ -797,6 +811,11 @@ const safeProviders: SafeProvider[] = [ useClass: SendApiService, deps: [ApiServiceAbstraction, FileUploadServiceAbstraction, InternalSendService], }), + safeProvider({ + provide: KeyApiService, + useClass: DefaultKeyApiService, + deps: [ApiServiceAbstraction], + }), safeProvider({ provide: SyncService, useClass: DefaultSyncService, @@ -825,6 +844,7 @@ const safeProviders: SafeProvider[] = [ TokenServiceAbstraction, AuthServiceAbstraction, StateProvider, + SecurityStateService, ], }), safeProvider({ @@ -1059,6 +1079,11 @@ const safeProviders: SafeProvider[] = [ provide: MasterPasswordServiceAbstraction, useExisting: InternalMasterPasswordServiceAbstraction, }), + safeProvider({ + provide: MasterPasswordUnlockService, + useClass: DefaultMasterPasswordUnlockService, + deps: [InternalMasterPasswordServiceAbstraction, KeyService], + }), safeProvider({ provide: KeyConnectorServiceAbstraction, useClass: KeyConnectorService, @@ -1399,6 +1424,11 @@ const safeProviders: SafeProvider[] = [ useClass: BillingApiService, deps: [ApiServiceAbstraction], }), + safeProvider({ + provide: OrganizationMetadataServiceAbstraction, + useClass: DefaultOrganizationMetadataService, + deps: [BillingApiServiceAbstraction, ConfigService], + }), safeProvider({ provide: BillingAccountProfileStateService, useClass: DefaultBillingAccountProfileStateService, @@ -1523,6 +1553,7 @@ const safeProviders: SafeProvider[] = [ AccountServiceAbstraction, KdfConfigService, KeyService, + SecurityStateService, ApiServiceAbstraction, StateProvider, ConfigService, diff --git a/libs/angular/src/tools/send/add-edit.component.ts b/libs/angular/src/tools/send/add-edit.component.ts index 221b751528a..f87b5f9bf86 100644 --- a/libs/angular/src/tools/send/add-edit.component.ts +++ b/libs/angular/src/tools/send/add-edit.component.ts @@ -260,12 +260,19 @@ export class AddEditComponent implements OnInit, OnDestroy { }); if (this.editMode) { - this.sendService - .get$(this.sendId) + this.accountService.activeAccount$ .pipe( - //Promise.reject will complete the BehaviourSubject, if desktop starts relying only on BehaviourSubject, this should be changed. - concatMap((s) => - s instanceof Send ? s.decrypt() : Promise.reject(new Error("Failed to load send.")), + getUserId, + switchMap((userId) => + this.sendService + .get$(this.sendId) + .pipe( + concatMap((s) => + s instanceof Send + ? s.decrypt(userId) + : Promise.reject(new Error("Failed to load send.")), + ), + ), ), takeUntil(this.destroy$), ) diff --git a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts index b5695e2e8a0..1dbb8053e97 100644 --- a/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts +++ b/libs/common/src/billing/abstractions/billing-api.service.abstraction.ts @@ -4,6 +4,7 @@ import { SubscriptionCancellationRequest } from "../../billing/models/request/su import { OrganizationBillingMetadataResponse } from "../../billing/models/response/organization-billing-metadata.response"; import { PlanResponse } from "../../billing/models/response/plan.response"; import { ListResponse } from "../../models/response/list.response"; +import { OrganizationId } from "../../types/guid"; import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request"; import { UpdateClientOrganizationRequest } from "../models/request/update-client-organization.request"; import { InvoicesResponse } from "../models/response/invoices.response"; @@ -23,7 +24,11 @@ export abstract class BillingApiServiceAbstraction { ): Promise; abstract getOrganizationBillingMetadata( - organizationId: string, + organizationId: OrganizationId, + ): Promise; + + abstract getOrganizationBillingMetadataVNext( + organizationId: OrganizationId, ): Promise; abstract getPlans(): Promise>; diff --git a/libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts b/libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts new file mode 100644 index 00000000000..c16d4112273 --- /dev/null +++ b/libs/common/src/billing/abstractions/organization-metadata.service.abstraction.ts @@ -0,0 +1,12 @@ +import { Observable } from "rxjs"; + +import { OrganizationId } from "../../types/guid"; +import { OrganizationBillingMetadataResponse } from "../models/response/organization-billing-metadata.response"; + +export abstract class OrganizationMetadataServiceAbstraction { + abstract getOrganizationMetadata$( + organizationId: OrganizationId, + ): Observable; + + abstract refreshMetadataCache(): void; +} diff --git a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts index aa34c37bd1d..366d10a1dca 100644 --- a/libs/common/src/billing/models/response/organization-billing-metadata.response.ts +++ b/libs/common/src/billing/models/response/organization-billing-metadata.response.ts @@ -1,35 +1,12 @@ import { BaseResponse } from "../../../models/response/base.response"; export class OrganizationBillingMetadataResponse extends BaseResponse { - isEligibleForSelfHost: boolean; - isManaged: boolean; isOnSecretsManagerStandalone: boolean; - isSubscriptionUnpaid: boolean; - hasSubscription: boolean; - hasOpenInvoice: boolean; - invoiceDueDate: Date | null; - invoiceCreatedDate: Date | null; - subPeriodEndDate: Date | null; - isSubscriptionCanceled: boolean; organizationOccupiedSeats: number; constructor(response: any) { super(response); - this.isEligibleForSelfHost = this.getResponseProperty("IsEligibleForSelfHost"); - this.isManaged = this.getResponseProperty("IsManaged"); this.isOnSecretsManagerStandalone = this.getResponseProperty("IsOnSecretsManagerStandalone"); - this.isSubscriptionUnpaid = this.getResponseProperty("IsSubscriptionUnpaid"); - this.hasSubscription = this.getResponseProperty("HasSubscription"); - this.hasOpenInvoice = this.getResponseProperty("HasOpenInvoice"); - - this.invoiceDueDate = this.parseDate(this.getResponseProperty("InvoiceDueDate")); - this.invoiceCreatedDate = this.parseDate(this.getResponseProperty("InvoiceCreatedDate")); - this.subPeriodEndDate = this.parseDate(this.getResponseProperty("SubPeriodEndDate")); - this.isSubscriptionCanceled = this.getResponseProperty("IsSubscriptionCanceled"); this.organizationOccupiedSeats = this.getResponseProperty("OrganizationOccupiedSeats"); } - - private parseDate(dateString: any): Date | null { - return dateString ? new Date(dateString) : null; - } } diff --git a/libs/common/src/billing/services/billing-api.service.ts b/libs/common/src/billing/services/billing-api.service.ts index a34809e9f02..c953d920055 100644 --- a/libs/common/src/billing/services/billing-api.service.ts +++ b/libs/common/src/billing/services/billing-api.service.ts @@ -5,6 +5,7 @@ import { ApiService } from "../../abstractions/api.service"; import { OrganizationCreateRequest } from "../../admin-console/models/request/organization-create.request"; import { ProviderOrganizationOrganizationDetailsResponse } from "../../admin-console/models/response/provider/provider-organization.response"; import { ListResponse } from "../../models/response/list.response"; +import { OrganizationId } from "../../types/guid"; import { BillingApiServiceAbstraction } from "../abstractions"; import { CreateClientOrganizationRequest } from "../models/request/create-client-organization.request"; import { SubscriptionCancellationRequest } from "../models/request/subscription-cancellation.request"; @@ -48,7 +49,7 @@ export class BillingApiService implements BillingApiServiceAbstraction { } async getOrganizationBillingMetadata( - organizationId: string, + organizationId: OrganizationId, ): Promise { const r = await this.apiService.send( "GET", @@ -61,6 +62,20 @@ export class BillingApiService implements BillingApiServiceAbstraction { return new OrganizationBillingMetadataResponse(r); } + async getOrganizationBillingMetadataVNext( + organizationId: OrganizationId, + ): Promise { + const r = await this.apiService.send( + "GET", + "/organizations/" + organizationId + "/billing/vnext/metadata", + null, + true, + true, + ); + + return new OrganizationBillingMetadataResponse(r); + } + async getPlans(): Promise> { const r = await this.apiService.send("GET", "/plans", null, false, true); return new ListResponse(r, PlanResponse); diff --git a/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts b/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts new file mode 100644 index 00000000000..0ed60bef605 --- /dev/null +++ b/libs/common/src/billing/services/organization/organization-metadata.service.spec.ts @@ -0,0 +1,276 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, firstValueFrom } from "rxjs"; + +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; +import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { newGuid } from "@bitwarden/guid"; + +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { OrganizationId } from "../../../types/guid"; + +import { DefaultOrganizationMetadataService } from "./organization-metadata.service"; + +describe("DefaultOrganizationMetadataService", () => { + let service: DefaultOrganizationMetadataService; + let billingApiService: jest.Mocked; + let configService: jest.Mocked; + let featureFlagSubject: BehaviorSubject; + + const mockOrganizationId = newGuid() as OrganizationId; + const mockOrganizationId2 = newGuid() as OrganizationId; + + const createMockMetadataResponse = ( + isOnSecretsManagerStandalone = false, + organizationOccupiedSeats = 5, + ): OrganizationBillingMetadataResponse => { + return { + isOnSecretsManagerStandalone, + organizationOccupiedSeats, + } as OrganizationBillingMetadataResponse; + }; + + beforeEach(() => { + billingApiService = mock(); + configService = mock(); + featureFlagSubject = new BehaviorSubject(false); + + configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable()); + + service = new DefaultOrganizationMetadataService(billingApiService, configService); + }); + + afterEach(() => { + jest.resetAllMocks(); + featureFlagSubject.complete(); + }); + + describe("getOrganizationMetadata$", () => { + describe("feature flag OFF", () => { + beforeEach(() => { + featureFlagSubject.next(false); + }); + + it("calls getOrganizationBillingMetadata when feature flag is off", async () => { + const mockResponse = createMockMetadataResponse(false, 10); + billingApiService.getOrganizationBillingMetadata.mockResolvedValue(mockResponse); + + const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + + expect(configService.getFeatureFlag$).toHaveBeenCalledWith( + FeatureFlag.PM25379_UseNewOrganizationMetadataStructure, + ); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledWith( + mockOrganizationId, + ); + expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled(); + expect(result).toEqual(mockResponse); + }); + + it("does not cache metadata when feature flag is off", async () => { + const mockResponse1 = createMockMetadataResponse(false, 10); + const mockResponse2 = createMockMetadataResponse(false, 15); + billingApiService.getOrganizationBillingMetadata + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(2); + expect(result1).toEqual(mockResponse1); + expect(result2).toEqual(mockResponse2); + }); + }); + + describe("feature flag ON", () => { + beforeEach(() => { + featureFlagSubject.next(true); + }); + + it("calls getOrganizationBillingMetadataVNext when feature flag is on", async () => { + const mockResponse = createMockMetadataResponse(true, 15); + billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse); + + const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + + expect(configService.getFeatureFlag$).toHaveBeenCalledWith( + FeatureFlag.PM25379_UseNewOrganizationMetadataStructure, + ); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledWith( + mockOrganizationId, + ); + expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled(); + expect(result).toEqual(mockResponse); + }); + + it("caches metadata by organization ID when feature flag is on", async () => { + const mockResponse = createMockMetadataResponse(true, 10); + billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse); + + const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1); + expect(result1).toEqual(mockResponse); + expect(result2).toEqual(mockResponse); + }); + + it("maintains separate cache entries for different organization IDs", async () => { + const mockResponse1 = createMockMetadataResponse(true, 10); + const mockResponse2 = createMockMetadataResponse(false, 20); + billingApiService.getOrganizationBillingMetadataVNext + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const result1 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result2 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2)); + const result3 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId)); + const result4 = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId2)); + + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith( + 1, + mockOrganizationId, + ); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenNthCalledWith( + 2, + mockOrganizationId2, + ); + expect(result1).toEqual(mockResponse1); + expect(result2).toEqual(mockResponse2); + expect(result3).toEqual(mockResponse1); + expect(result4).toEqual(mockResponse2); + }); + }); + + describe("shareReplay behavior", () => { + beforeEach(() => { + featureFlagSubject.next(true); + }); + + it("does not call API multiple times when the same cached observable is subscribed to multiple times", async () => { + const mockResponse = createMockMetadataResponse(true, 10); + billingApiService.getOrganizationBillingMetadataVNext.mockResolvedValue(mockResponse); + + const metadata$ = service.getOrganizationMetadata$(mockOrganizationId); + + const subscription1Promise = firstValueFrom(metadata$); + const subscription2Promise = firstValueFrom(metadata$); + const subscription3Promise = firstValueFrom(metadata$); + + const [result1, result2, result3] = await Promise.all([ + subscription1Promise, + subscription2Promise, + subscription3Promise, + ]); + + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(1); + expect(result1).toEqual(mockResponse); + expect(result2).toEqual(mockResponse); + expect(result3).toEqual(mockResponse); + }); + }); + }); + + describe("refreshMetadataCache", () => { + beforeEach(() => { + featureFlagSubject.next(true); + }); + + it("refreshes cached metadata when called with feature flag on", (done) => { + const mockResponse1 = createMockMetadataResponse(true, 10); + const mockResponse2 = createMockMetadataResponse(true, 20); + let invocationCount = 0; + + billingApiService.getOrganizationBillingMetadataVNext + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({ + next: (result) => { + invocationCount++; + + if (invocationCount === 1) { + expect(result).toEqual(mockResponse1); + } else if (invocationCount === 2) { + expect(result).toEqual(mockResponse2); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(2); + subscription.unsubscribe(); + done(); + } + }, + error: done.fail, + }); + + setTimeout(() => { + service.refreshMetadataCache(); + }, 10); + }); + + it("does not trigger refresh when feature flag is disabled", async () => { + featureFlagSubject.next(false); + + const mockResponse1 = createMockMetadataResponse(false, 10); + const mockResponse2 = createMockMetadataResponse(false, 20); + let invocationCount = 0; + + billingApiService.getOrganizationBillingMetadata + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2); + + const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({ + next: () => { + invocationCount++; + }, + }); + + // wait for initial invocation + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(invocationCount).toBe(1); + + service.refreshMetadataCache(); + + // wait to ensure no additional invocations + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(invocationCount).toBe(1); + expect(billingApiService.getOrganizationBillingMetadata).toHaveBeenCalledTimes(1); + + subscription.unsubscribe(); + }); + + it("bypasses cache when refreshing metadata", (done) => { + const mockResponse1 = createMockMetadataResponse(true, 10); + const mockResponse2 = createMockMetadataResponse(true, 20); + const mockResponse3 = createMockMetadataResponse(true, 30); + let invocationCount = 0; + + billingApiService.getOrganizationBillingMetadataVNext + .mockResolvedValueOnce(mockResponse1) + .mockResolvedValueOnce(mockResponse2) + .mockResolvedValueOnce(mockResponse3); + + const subscription = service.getOrganizationMetadata$(mockOrganizationId).subscribe({ + next: (result) => { + invocationCount++; + + if (invocationCount === 1) { + expect(result).toEqual(mockResponse1); + service.refreshMetadataCache(); + } else if (invocationCount === 2) { + expect(result).toEqual(mockResponse2); + service.refreshMetadataCache(); + } else if (invocationCount === 3) { + expect(result).toEqual(mockResponse3); + expect(billingApiService.getOrganizationBillingMetadataVNext).toHaveBeenCalledTimes(3); + subscription.unsubscribe(); + done(); + } + }, + error: done.fail, + }); + }); + }); +}); diff --git a/libs/common/src/billing/services/organization/organization-metadata.service.ts b/libs/common/src/billing/services/organization/organization-metadata.service.ts new file mode 100644 index 00000000000..09aaa202112 --- /dev/null +++ b/libs/common/src/billing/services/organization/organization-metadata.service.ts @@ -0,0 +1,74 @@ +import { filter, from, merge, Observable, shareReplay, Subject, switchMap } from "rxjs"; + +import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions"; + +import { FeatureFlag } from "../../../enums/feature-flag.enum"; +import { ConfigService } from "../../../platform/abstractions/config/config.service"; +import { OrganizationId } from "../../../types/guid"; +import { OrganizationMetadataServiceAbstraction } from "../../abstractions/organization-metadata.service.abstraction"; +import { OrganizationBillingMetadataResponse } from "../../models/response/organization-billing-metadata.response"; + +export class DefaultOrganizationMetadataService implements OrganizationMetadataServiceAbstraction { + private metadataCache = new Map< + OrganizationId, + Observable + >(); + + constructor( + private billingApiService: BillingApiServiceAbstraction, + private configService: ConfigService, + ) {} + private refreshMetadataTrigger = new Subject(); + + refreshMetadataCache = () => this.refreshMetadataTrigger.next(); + + getOrganizationMetadata$ = ( + organizationId: OrganizationId, + ): Observable => + this.configService + .getFeatureFlag$(FeatureFlag.PM25379_UseNewOrganizationMetadataStructure) + .pipe( + switchMap((featureFlagEnabled) => { + return merge( + this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled), + this.refreshMetadataTrigger.pipe( + filter(() => featureFlagEnabled), + switchMap(() => + this.getOrganizationMetadataInternal$(organizationId, featureFlagEnabled, true), + ), + ), + ); + }), + ); + + private getOrganizationMetadataInternal$( + organizationId: OrganizationId, + featureFlagEnabled: boolean, + bypassCache: boolean = false, + ): Observable { + if (!bypassCache && featureFlagEnabled && this.metadataCache.has(organizationId)) { + return this.metadataCache.get(organizationId)!; + } + + const metadata$ = from(this.fetchMetadata(organizationId, featureFlagEnabled)).pipe( + shareReplay({ bufferSize: 1, refCount: false }), + ); + + if (featureFlagEnabled) { + this.metadataCache.set(organizationId, metadata$); + } + + return metadata$; + } + + private async fetchMetadata( + organizationId: OrganizationId, + featureFlagEnabled: boolean, + ): Promise { + if (featureFlagEnabled) { + return await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId); + } + + return await this.billingApiService.getOrganizationBillingMetadata(organizationId); + } +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 0897aab33c9..e2d4b000626 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -25,17 +25,19 @@ export enum FeatureFlag { PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover", PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings", + PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button", + PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure", PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog", /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation", ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", + UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data", /* Tools */ DesktopSendUIRefresh = "desktop-send-ui-refresh", UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators", - UseChromiumImporter = "pm-23982-chromium-importer", /* DIRT */ EventBasedOrganizationIntegrations = "event-based-organization-integrations", @@ -81,7 +83,6 @@ export const DefaultFeatureFlagValue = { /* Tools */ [FeatureFlag.DesktopSendUIRefresh]: FALSE, [FeatureFlag.UseSdkPasswordGenerators]: FALSE, - [FeatureFlag.UseChromiumImporter]: FALSE, /* DIRT */ [FeatureFlag.EventBasedOrganizationIntegrations]: FALSE, @@ -102,12 +103,15 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE, [FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE, [FeatureFlag.PM22415_TaxIDWarnings]: FALSE, + [FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE, + [FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE, [FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE, /* Key Management */ [FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.EnrollAeadOnKeyRotation]: FALSE, [FeatureFlag.ForceUpdateKDFSettings]: FALSE, + [FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE, /* Platform */ [FeatureFlag.IpcChannelFramework]: FALSE, diff --git a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts index 175da716803..11c186bc393 100644 --- a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts +++ b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts @@ -8,6 +8,7 @@ import { } from "../../../platform/models/domain/decrypt-parameters"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "../../../types/csprng"; +import { UnsignedPublicKey } from "../../types"; import { CryptoFunctionService } from "../abstractions/crypto-function.service"; export class WebCryptoFunctionService implements CryptoFunctionService { @@ -309,7 +310,7 @@ export class WebCryptoFunctionService implements CryptoFunctionService { "encrypt", ]); const buffer = await this.subtle.exportKey("spki", impPublicKey); - return new Uint8Array(buffer); + return new Uint8Array(buffer) as UnsignedPublicKey; } async aesGenerateKey(bitLength = 128 | 192 | 256 | 512): Promise { diff --git a/libs/common/src/key-management/enums/signing-key-type.enum.ts b/libs/common/src/key-management/enums/signing-key-type.enum.ts new file mode 100644 index 00000000000..b1370ef0034 --- /dev/null +++ b/libs/common/src/key-management/enums/signing-key-type.enum.ts @@ -0,0 +1,13 @@ +export const SigningKeyTypes = { + Ed25519: "ed25519", +} as const; + +export type SigningKeyType = (typeof SigningKeyTypes)[keyof typeof SigningKeyTypes]; +export function parseSigningKeyTypeFromString(value: string): SigningKeyType { + switch (value) { + case SigningKeyTypes.Ed25519: + return SigningKeyTypes.Ed25519; + default: + throw new Error(`Unknown signing key type: ${value}`); + } +} diff --git a/libs/common/src/key-management/keys/response/private-keys.response.ts b/libs/common/src/key-management/keys/response/private-keys.response.ts new file mode 100644 index 00000000000..2bd723fb455 --- /dev/null +++ b/libs/common/src/key-management/keys/response/private-keys.response.ts @@ -0,0 +1,55 @@ +import { SecurityStateResponse } from "../../security-state/response/security-state.response"; + +import { PublicKeyEncryptionKeyPairResponse } from "./public-key-encryption-key-pair.response"; +import { SignatureKeyPairResponse } from "./signature-key-pair.response"; + +/** + * The privately accessible view of an entity (account / org)'s keys. + * This includes the full key-pairs for public-key encryption and signing, as well as the security state if available. + */ +export class PrivateKeysResponseModel { + readonly publicKeyEncryptionKeyPair: PublicKeyEncryptionKeyPairResponse; + readonly signatureKeyPair: SignatureKeyPairResponse | null = null; + readonly securityState: SecurityStateResponse | null = null; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if ( + !("publicKeyEncryptionKeyPair" in response) || + typeof response.publicKeyEncryptionKeyPair !== "object" + ) { + throw new TypeError("Response must contain a valid publicKeyEncryptionKeyPair"); + } + this.publicKeyEncryptionKeyPair = new PublicKeyEncryptionKeyPairResponse( + response.publicKeyEncryptionKeyPair, + ); + + if ( + "signatureKeyPair" in response && + typeof response.signatureKeyPair === "object" && + response.signatureKeyPair != null + ) { + this.signatureKeyPair = new SignatureKeyPairResponse(response.signatureKeyPair); + } + + if ( + "securityState" in response && + typeof response.securityState === "object" && + response.securityState != null + ) { + this.securityState = new SecurityStateResponse(response.securityState); + } + + if ( + (this.signatureKeyPair !== null && this.securityState === null) || + (this.signatureKeyPair === null && this.securityState !== null) + ) { + throw new TypeError( + "Both signatureKeyPair and securityState must be present or absent together", + ); + } + } +} diff --git a/libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts b/libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts new file mode 100644 index 00000000000..4b1e3f90e29 --- /dev/null +++ b/libs/common/src/key-management/keys/response/public-key-encryption-key-pair.response.ts @@ -0,0 +1,32 @@ +import { Utils } from "@bitwarden/common/platform/misc/utils"; + +import { SignedPublicKey, UnsignedPublicKey, WrappedPrivateKey } from "../../types"; + +export class PublicKeyEncryptionKeyPairResponse { + readonly wrappedPrivateKey: WrappedPrivateKey; + readonly publicKey: UnsignedPublicKey; + + readonly signedPublicKey: SignedPublicKey | null = null; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if (!("publicKey" in response) || typeof response.publicKey !== "string") { + throw new TypeError("Response must contain a valid publicKey"); + } + this.publicKey = Utils.fromB64ToArray(response.publicKey) as UnsignedPublicKey; + + if (!("wrappedPrivateKey" in response) || typeof response.wrappedPrivateKey !== "string") { + throw new TypeError("Response must contain a valid wrappedPrivateKey"); + } + this.wrappedPrivateKey = response.wrappedPrivateKey as WrappedPrivateKey; + + if ("signedPublicKey" in response && typeof response.signedPublicKey === "string") { + this.signedPublicKey = response.signedPublicKey as SignedPublicKey; + } else { + this.signedPublicKey = null; + } + } +} diff --git a/libs/common/src/key-management/keys/response/public-keys.response.ts b/libs/common/src/key-management/keys/response/public-keys.response.ts new file mode 100644 index 00000000000..cf4b3efc349 --- /dev/null +++ b/libs/common/src/key-management/keys/response/public-keys.response.ts @@ -0,0 +1,44 @@ +import { SignedPublicKey } from "@bitwarden/sdk-internal"; + +import { UnsignedPublicKey, VerifyingKey } from "../../types"; + +/** + * The publicly accessible view of an entity (account / org)'s keys. That includes the encryption public key, and the verifying key if available. + */ +export class PublicKeysResponseModel { + readonly publicKey: UnsignedPublicKey; + readonly verifyingKey: VerifyingKey | null; + readonly signedPublicKey?: SignedPublicKey | null; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if (!("publicKey" in response) || !(response.publicKey instanceof Uint8Array)) { + throw new TypeError("Response must contain a valid publicKey"); + } + this.publicKey = response.publicKey as UnsignedPublicKey; + + if ("verifyingKey" in response && typeof response.verifyingKey === "string") { + this.verifyingKey = response.verifyingKey as VerifyingKey; + } else { + this.verifyingKey = null; + } + + if ("signedPublicKey" in response && typeof response.signedPublicKey === "string") { + this.signedPublicKey = response.signedPublicKey as SignedPublicKey; + } else { + this.signedPublicKey = null; + } + + if ( + (this.signedPublicKey !== null && this.verifyingKey === null) || + (this.signedPublicKey === null && this.verifyingKey !== null) + ) { + throw new TypeError( + "Both signedPublicKey and verifyingKey must be present or absent together", + ); + } + } +} diff --git a/libs/common/src/key-management/keys/response/signature-key-pair.response.ts b/libs/common/src/key-management/keys/response/signature-key-pair.response.ts new file mode 100644 index 00000000000..2499839b64e --- /dev/null +++ b/libs/common/src/key-management/keys/response/signature-key-pair.response.ts @@ -0,0 +1,22 @@ +import { VerifyingKey, WrappedSigningKey } from "../../types"; + +export class SignatureKeyPairResponse { + readonly wrappedSigningKey: WrappedSigningKey; + readonly verifyingKey: VerifyingKey; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if (!("wrappedSigningKey" in response) || typeof response.wrappedSigningKey !== "string") { + throw new TypeError("Response must contain a valid wrappedSigningKey"); + } + this.wrappedSigningKey = response.wrappedSigningKey as WrappedSigningKey; + + if (!("verifyingKey" in response) || typeof response.verifyingKey !== "string") { + throw new TypeError("Response must contain a valid verifyingKey"); + } + this.verifyingKey = response.verifyingKey as VerifyingKey; + } +} diff --git a/libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts b/libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts new file mode 100644 index 00000000000..93556dbb57a --- /dev/null +++ b/libs/common/src/key-management/keys/services/abstractions/key-api-service.abstraction.ts @@ -0,0 +1,5 @@ +import { PublicKeysResponseModel } from "../../response/public-keys.response"; + +export abstract class KeyApiService { + abstract getUserPublicKeys(id: string): Promise; +} diff --git a/libs/common/src/key-management/keys/services/default-key-api-service.service.ts b/libs/common/src/key-management/keys/services/default-key-api-service.service.ts new file mode 100644 index 00000000000..fd8321055be --- /dev/null +++ b/libs/common/src/key-management/keys/services/default-key-api-service.service.ts @@ -0,0 +1,15 @@ +import { UserId } from "@bitwarden/common/types/guid"; + +import { ApiService } from "../../../abstractions/api.service"; +import { PublicKeysResponseModel } from "../response/public-keys.response"; + +import { KeyApiService } from "./abstractions/key-api-service.abstraction"; + +export class DefaultKeyApiService implements KeyApiService { + constructor(private apiService: ApiService) {} + + async getUserPublicKeys(id: UserId): Promise { + const response = await this.apiService.send("GET", "/users/" + id + "/keys", null, true, true); + return new PublicKeysResponseModel(response); + } +} diff --git a/libs/common/src/key-management/master-password/abstractions/master-password-unlock.service.ts b/libs/common/src/key-management/master-password/abstractions/master-password-unlock.service.ts new file mode 100644 index 00000000000..4448206b2f6 --- /dev/null +++ b/libs/common/src/key-management/master-password/abstractions/master-password-unlock.service.ts @@ -0,0 +1,13 @@ +import { UserId } from "@bitwarden/user-core"; + +import { UserKey } from "../../../types/key"; + +export abstract class MasterPasswordUnlockService { + /** + * Unlocks the user's account using the master password. + * @param masterPassword The master password provided by the user. + * @param userId The ID of the active user. + * @returns the user's decrypted userKey. + */ + abstract unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise; +} diff --git a/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts b/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts index 8ef14904bce..f982c2c5ce8 100644 --- a/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts +++ b/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts @@ -171,4 +171,12 @@ export abstract class InternalMasterPasswordServiceAbstraction extends MasterPas masterPasswordUnlockData: MasterPasswordUnlockData, userId: UserId, ): Promise; + + /** + * An observable that emits the master password unlock data for the target user. + * @param userId The user ID. + * @throws If the user ID is null or undefined. + * @returns An observable that emits the master password unlock data or null if not found. + */ + abstract masterPasswordUnlockData$(userId: UserId): Observable; } diff --git a/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.spec.ts b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.spec.ts new file mode 100644 index 00000000000..75668e8e6bd --- /dev/null +++ b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.spec.ts @@ -0,0 +1,154 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { newGuid } from "@bitwarden/guid"; +// eslint-disable-next-line no-restricted-imports +import { Argon2KdfConfig, KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; + +import { HashPurpose } from "../../../platform/enums"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { MasterKey, UserKey } from "../../../types/key"; +import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; +import { + MasterKeyWrappedUserKey, + MasterPasswordSalt, + MasterPasswordUnlockData, +} from "../types/master-password.types"; + +import { DefaultMasterPasswordUnlockService } from "./default-master-password-unlock.service"; + +describe("DefaultMasterPasswordUnlockService", () => { + let sut: DefaultMasterPasswordUnlockService; + + let masterPasswordService: MockProxy; + let keyService: MockProxy; + + const mockMasterPassword = "testExample"; + const mockUserId = newGuid() as UserId; + + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + const mockMasterPasswordUnlockData: MasterPasswordUnlockData = new MasterPasswordUnlockData( + "user@example.com" as MasterPasswordSalt, + new Argon2KdfConfig(100000, 64, 1), + "encryptedMasterKeyWrappedUserKey" as MasterKeyWrappedUserKey, + ); + + //Legacy data for tests + const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey; + const mockKeyHash = "localKeyHash"; + + beforeEach(() => { + masterPasswordService = mock(); + keyService = mock(); + + sut = new DefaultMasterPasswordUnlockService(masterPasswordService, keyService); + + masterPasswordService.masterPasswordUnlockData$.mockReturnValue( + of(mockMasterPasswordUnlockData), + ); + masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData.mockResolvedValue(mockUserKey); + + // Legacy state mocking + keyService.makeMasterKey.mockResolvedValue(mockMasterKey); + keyService.hashMasterKey.mockResolvedValue(mockKeyHash); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("unlockWithMasterPassword", () => { + test.each([null as unknown as string, undefined as unknown as string, ""])( + "throws when the provided master password is %s", + async (masterPassword) => { + await expect(sut.unlockWithMasterPassword(masterPassword, mockUserId)).rejects.toThrow( + "Master password is required", + ); + expect(masterPasswordService.masterPasswordUnlockData$).not.toHaveBeenCalled(); + expect( + masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData, + ).not.toHaveBeenCalled(); + }, + ); + + test.each([null as unknown as UserId, undefined as unknown as UserId])( + "throws when the provided master password is %s", + async (userId) => { + await expect(sut.unlockWithMasterPassword(mockMasterPassword, userId)).rejects.toThrow( + "User ID is required", + ); + }, + ); + + it("throws an error when the user doesn't have masterPasswordUnlockData", async () => { + masterPasswordService.masterPasswordUnlockData$.mockReturnValue(of(null)); + + await expect(sut.unlockWithMasterPassword(mockMasterPassword, mockUserId)).rejects.toThrow( + "Master password unlock data was not found for the user " + mockUserId, + ); + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect( + masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData, + ).not.toHaveBeenCalled(); + }); + + it("returns userKey successfully", async () => { + const result = await sut.unlockWithMasterPassword(mockMasterPassword, mockUserId); + + expect(result).toEqual(mockUserKey); + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData, + ); + }); + + it("sets legacy state on success", async () => { + const result = await sut.unlockWithMasterPassword(mockMasterPassword, mockUserId); + + expect(result).toEqual(mockUserKey); + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData, + ); + + expect(keyService.makeMasterKey).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData.salt, + mockMasterPasswordUnlockData.kdf, + ); + expect(keyService.hashMasterKey).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterKey, + HashPurpose.LocalAuthorization, + ); + expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(mockKeyHash, mockUserId); + expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith(mockMasterKey, mockUserId); + }); + + it("throws an error if masterKey construction fails", async () => { + keyService.makeMasterKey.mockResolvedValue(null as unknown as MasterKey); + + await expect(sut.unlockWithMasterPassword(mockMasterPassword, mockUserId)).rejects.toThrow( + "Master key could not be created to set legacy master password state.", + ); + + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData, + ); + + expect(keyService.makeMasterKey).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData.salt, + mockMasterPasswordUnlockData.kdf, + ); + expect(keyService.hashMasterKey).not.toHaveBeenCalled(); + expect(masterPasswordService.setMasterKeyHash).not.toHaveBeenCalled(); + expect(masterPasswordService.setMasterKey).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.ts b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.ts new file mode 100644 index 00000000000..87114000abf --- /dev/null +++ b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.ts @@ -0,0 +1,75 @@ +import { firstValueFrom } from "rxjs"; + +// eslint-disable-next-line no-restricted-imports +import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; + +import { HashPurpose } from "../../../platform/enums"; +import { UserKey } from "../../../types/key"; +import { MasterPasswordUnlockService } from "../abstractions/master-password-unlock.service"; +import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; +import { MasterPasswordUnlockData } from "../types/master-password.types"; + +export class DefaultMasterPasswordUnlockService implements MasterPasswordUnlockService { + constructor( + private readonly masterPasswordService: InternalMasterPasswordServiceAbstraction, + private readonly keyService: KeyService, + ) {} + + async unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise { + this.validateInput(masterPassword, userId); + + const masterPasswordUnlockData = await firstValueFrom( + this.masterPasswordService.masterPasswordUnlockData$(userId), + ); + + if (masterPasswordUnlockData == null) { + throw new Error("Master password unlock data was not found for the user " + userId); + } + + const userKey = await this.masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData( + masterPassword, + masterPasswordUnlockData, + ); + + await this.setLegacyState(masterPassword, masterPasswordUnlockData, userId); + + return userKey; + } + + private validateInput(masterPassword: string, userId: UserId): void { + if (masterPassword == null || masterPassword === "") { + throw new Error("Master password is required"); + } + if (userId == null) { + throw new Error("User ID is required"); + } + } + + // Previously unlocking had the side effect of setting the masterKey and masterPasswordHash in state. + // This is to preserve that behavior, once masterKey and masterPasswordHash state is removed this should be removed as well. + private async setLegacyState( + masterPassword: string, + masterPasswordUnlockData: MasterPasswordUnlockData, + userId: UserId, + ): Promise { + const masterKey = await this.keyService.makeMasterKey( + masterPassword, + masterPasswordUnlockData.salt, + masterPasswordUnlockData.kdf, + ); + + if (!masterKey) { + throw new Error("Master key could not be created to set legacy master password state."); + } + + const localKeyHash = await this.keyService.hashMasterKey( + masterPassword, + masterKey, + HashPurpose.LocalAuthorization, + ); + + await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId); + await this.masterPasswordService.setMasterKey(masterKey, userId); + } +} diff --git a/libs/common/src/key-management/master-password/services/fake-master-password.service.ts b/libs/common/src/key-management/master-password/services/fake-master-password.service.ts index 81aea5e480a..5db7f178b18 100644 --- a/libs/common/src/key-management/master-password/services/fake-master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/fake-master-password.service.ts @@ -119,4 +119,8 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA ): Promise { return this.mock.setMasterPasswordUnlockData(masterPasswordUnlockData, userId); } + + masterPasswordUnlockData$(userId: UserId): Observable { + return this.mock.masterPasswordUnlockData$(userId); + } } diff --git a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts index 02b4e9a895a..f5fee3be4c5 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts @@ -1,6 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; -import * as rxjs from "rxjs"; -import { firstValueFrom, of } from "rxjs"; +import { firstValueFrom } from "rxjs"; import { Jsonify } from "type-fest"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; @@ -10,6 +9,7 @@ import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden import { FakeAccountService, + FakeStateProvider, makeSymmetricCryptoKey, mockAccountServiceWith, } from "../../../../spec"; @@ -17,7 +17,6 @@ import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-pa import { LogService } from "../../../platform/abstractions/log.service"; import { StateService } from "../../../platform/abstractions/state.service"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { MasterKey, UserKey } from "../../../types/key"; import { KeyGenerationService } from "../../crypto"; @@ -30,25 +29,30 @@ import { MasterPasswordUnlockData, } from "../types/master-password.types"; -import { MASTER_PASSWORD_UNLOCK_KEY, MasterPasswordService } from "./master-password.service"; +import { + FORCE_SET_PASSWORD_REASON, + MASTER_KEY_ENCRYPTED_USER_KEY, + MASTER_PASSWORD_UNLOCK_KEY, + MasterPasswordService, +} from "./master-password.service"; describe("MasterPasswordService", () => { let sut: MasterPasswordService; - let stateProvider: MockProxy; let stateService: MockProxy; let keyGenerationService: MockProxy; let encryptService: MockProxy; let logService: MockProxy; let cryptoFunctionService: MockProxy; let accountService: FakeAccountService; + let stateProvider: FakeStateProvider; const userId = "00000000-0000-0000-0000-000000000000" as UserId; - const mockUserState = { - state$: of(null), - update: jest.fn().mockResolvedValue(null), - }; + const kdfPBKDF2: KdfConfig = new PBKDF2KdfConfig(600_000); + const kdfArgon2: KdfConfig = new Argon2KdfConfig(4, 64, 3); + const salt = "test@bitwarden.com" as MasterPasswordSalt; + const userKey = makeSymmetricCryptoKey(64, 2) as UserKey; const testUserKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 1); const testMasterKey: MasterKey = makeSymmetricCryptoKey(32, 2); const testStretchedMasterKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 3); @@ -58,17 +62,13 @@ describe("MasterPasswordService", () => { "2.gbauOANURUHqvhLTDnva1A==|nSW+fPumiuTaDB/s12+JO88uemV6rhwRSR+YR1ZzGr5j6Ei3/h+XEli2Unpz652NlZ9NTuRpHxeOqkYYJtp7J+lPMoclgteXuAzUu9kqlRc=|DeUFkhIwgkGdZA08bDnDqMMNmZk21D+H5g8IostPKAY="; beforeEach(() => { - stateProvider = mock(); stateService = mock(); keyGenerationService = mock(); encryptService = mock(); logService = mock(); cryptoFunctionService = mock(); accountService = mockAccountServiceWith(userId); - - stateProvider.getUser.mockReturnValue(mockUserState as any); - - mockUserState.update.mockReset(); + stateProvider = new FakeStateProvider(accountService); sut = new MasterPasswordService( stateProvider, @@ -88,6 +88,10 @@ describe("MasterPasswordService", () => { }); }); + afterEach(() => { + jest.resetAllMocks(); + }); + describe("saltForUser$", () => { it("throws when userid not present", async () => { expect(() => { @@ -111,12 +115,10 @@ describe("MasterPasswordService", () => { await sut.setForceSetPasswordReason(reason, userId); - expect(stateProvider.getUser).toHaveBeenCalled(); - expect(mockUserState.update).toHaveBeenCalled(); - - // Call the update function to verify it returns the correct reason - const updateFn = mockUserState.update.mock.calls[0][0]; - expect(updateFn(null)).toBe(reason); + const state = await firstValueFrom( + stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$, + ); + expect(state).toEqual(reason); }); it("throws an error if reason is null", async () => { @@ -132,31 +134,29 @@ describe("MasterPasswordService", () => { }); it("does not overwrite AdminForcePasswordReset with other reasons except None", async () => { - jest - .spyOn(sut, "forceSetPasswordReason$") - .mockReturnValue(of(ForceSetPasswordReason.AdminForcePasswordReset)); - - jest - .spyOn(rxjs, "firstValueFrom") - .mockResolvedValue(ForceSetPasswordReason.AdminForcePasswordReset); + stateProvider.singleUser + .getFake(userId, FORCE_SET_PASSWORD_REASON) + .nextState(ForceSetPasswordReason.AdminForcePasswordReset); await sut.setForceSetPasswordReason(ForceSetPasswordReason.WeakMasterPassword, userId); - expect(mockUserState.update).not.toHaveBeenCalled(); + const state = await firstValueFrom( + stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$, + ); + expect(state).toEqual(ForceSetPasswordReason.AdminForcePasswordReset); }); it("allows overwriting AdminForcePasswordReset with None", async () => { - jest - .spyOn(sut, "forceSetPasswordReason$") - .mockReturnValue(of(ForceSetPasswordReason.AdminForcePasswordReset)); - - jest - .spyOn(rxjs, "firstValueFrom") - .mockResolvedValue(ForceSetPasswordReason.AdminForcePasswordReset); + stateProvider.singleUser + .getFake(userId, FORCE_SET_PASSWORD_REASON) + .nextState(ForceSetPasswordReason.AdminForcePasswordReset); await sut.setForceSetPasswordReason(ForceSetPasswordReason.None, userId); - expect(mockUserState.update).toHaveBeenCalled(); + const state = await firstValueFrom( + stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$, + ); + expect(state).toEqual(ForceSetPasswordReason.None); }); }); describe("decryptUserKeyWithMasterKey", () => { @@ -227,10 +227,10 @@ describe("MasterPasswordService", () => { await sut.setMasterKeyEncryptedUserKey(encryptedKey, userId); - expect(stateProvider.getUser).toHaveBeenCalled(); - expect(mockUserState.update).toHaveBeenCalled(); - const updateFn = mockUserState.update.mock.calls[0][0]; - expect(updateFn(null)).toEqual(encryptedKey.toJSON()); + const state = await firstValueFrom( + stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$, + ); + expect(state).toEqual(encryptedKey.toJSON()); }); }); @@ -328,11 +328,6 @@ describe("MasterPasswordService", () => { }); describe("setMasterPasswordUnlockData", () => { - const kdfPBKDF2: KdfConfig = new PBKDF2KdfConfig(600_000); - const kdfArgon2: KdfConfig = new Argon2KdfConfig(4, 64, 3); - const salt = "test@bitwarden.com" as MasterPasswordSalt; - const userKey = makeSymmetricCryptoKey(64, 2) as UserKey; - it.each([kdfPBKDF2, kdfArgon2])( "sets the master password unlock data kdf %o in the state", async (kdfConfig) => { @@ -345,11 +340,10 @@ describe("MasterPasswordService", () => { await sut.setMasterPasswordUnlockData(masterPasswordUnlockData, userId); - expect(stateProvider.getUser).toHaveBeenCalledWith(userId, MASTER_PASSWORD_UNLOCK_KEY); - expect(mockUserState.update).toHaveBeenCalled(); - - const updateFn = mockUserState.update.mock.calls[0][0]; - expect(updateFn(null)).toEqual(masterPasswordUnlockData.toJSON()); + const state = await firstValueFrom( + stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$, + ); + expect(state).toEqual(masterPasswordUnlockData.toJSON()); }, ); @@ -373,6 +367,40 @@ describe("MasterPasswordService", () => { }); }); + describe("masterPasswordUnlockData$", () => { + test.each([null as unknown as UserId, undefined as unknown as UserId])( + "throws when the provided userId is %s", + async (userId) => { + expect(() => sut.masterPasswordUnlockData$(userId)).toThrow("userId is null or undefined."); + }, + ); + + it("returns null when no data is set", async () => { + stateProvider.singleUser.getFake(userId, MASTER_PASSWORD_UNLOCK_KEY).nextState(null); + + const result = await firstValueFrom(sut.masterPasswordUnlockData$(userId)); + + expect(result).toBeNull(); + }); + + it.each([kdfPBKDF2, kdfArgon2])( + "returns the master password unlock data for kdf %o from state", + async (kdfConfig) => { + const masterPasswordUnlockData = await sut.makeMasterPasswordUnlockData( + "test-password", + kdfConfig, + salt, + userKey, + ); + await sut.setMasterPasswordUnlockData(masterPasswordUnlockData, userId); + + const result = await firstValueFrom(sut.masterPasswordUnlockData$(userId)); + + expect(result).toEqual(masterPasswordUnlockData.toJSON()); + }, + ); + }); + describe("MASTER_PASSWORD_UNLOCK_KEY", () => { it("has the correct configuration", () => { expect(MASTER_PASSWORD_UNLOCK_KEY.stateDefinition).toBeDefined(); diff --git a/libs/common/src/key-management/master-password/services/master-password.service.ts b/libs/common/src/key-management/master-password/services/master-password.service.ts index 9f7e054d64c..5cb6bb96a45 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.ts @@ -50,7 +50,7 @@ const MASTER_KEY_HASH = new UserKeyDefinition(MASTER_PASSWORD_DISK, "mas }); /** Disk to persist through lock */ -const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition( +export const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition( MASTER_PASSWORD_DISK, "masterKeyEncryptedUserKey", { @@ -60,7 +60,7 @@ const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition( ); /** Disk to persist through lock and account switches */ -const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition( +export const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition( MASTER_PASSWORD_DISK, "forceSetPasswordReason", { @@ -344,4 +344,10 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr .getUser(userId, MASTER_PASSWORD_UNLOCK_KEY) .update(() => masterPasswordUnlockData.toJSON()); } + + masterPasswordUnlockData$(userId: UserId): Observable { + assertNonNullish(userId, "userId"); + + return this.stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$; + } } diff --git a/libs/common/src/key-management/security-state/abstractions/security-state.service.ts b/libs/common/src/key-management/security-state/abstractions/security-state.service.ts new file mode 100644 index 00000000000..466095c2f45 --- /dev/null +++ b/libs/common/src/key-management/security-state/abstractions/security-state.service.ts @@ -0,0 +1,21 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { SignedSecurityState } from "../../types"; + +export abstract class SecurityStateService { + /** + * Retrieves the security state for the provided user. + * Note: This state is not yet validated. To get a validated state, the SDK crypto client + * must be used. This security state is validated on initialization of the SDK. + */ + abstract accountSecurityState$(userId: UserId): Observable; + /** + * Sets the security state for the provided user. + */ + abstract setAccountSecurityState( + accountSecurityState: SignedSecurityState, + userId: UserId, + ): Promise; +} diff --git a/libs/common/src/key-management/security-state/request/security-state.request.ts b/libs/common/src/key-management/security-state/request/security-state.request.ts new file mode 100644 index 00000000000..7c825bedf84 --- /dev/null +++ b/libs/common/src/key-management/security-state/request/security-state.request.ts @@ -0,0 +1,8 @@ +import { SignedSecurityState } from "../../types"; + +export class SecurityStateRequest { + constructor( + readonly securityState: SignedSecurityState, + readonly securityVersion: number, + ) {} +} diff --git a/libs/common/src/key-management/security-state/response/security-state.response.ts b/libs/common/src/key-management/security-state/response/security-state.response.ts new file mode 100644 index 00000000000..0590da31913 --- /dev/null +++ b/libs/common/src/key-management/security-state/response/security-state.response.ts @@ -0,0 +1,16 @@ +import { SignedSecurityState } from "../../types"; + +export class SecurityStateResponse { + readonly securityState: SignedSecurityState | null = null; + + constructor(response: unknown) { + if (typeof response !== "object" || response == null) { + throw new TypeError("Response must be an object"); + } + + if (!("securityState" in response) || !(typeof response.securityState === "string")) { + throw new TypeError("Response must contain a valid securityState"); + } + this.securityState = response.securityState as SignedSecurityState; + } +} diff --git a/libs/common/src/key-management/security-state/services/security-state.service.ts b/libs/common/src/key-management/security-state/services/security-state.service.ts new file mode 100644 index 00000000000..789d5171072 --- /dev/null +++ b/libs/common/src/key-management/security-state/services/security-state.service.ts @@ -0,0 +1,26 @@ +import { Observable } from "rxjs"; + +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { SignedSecurityState } from "../../types"; +import { SecurityStateService } from "../abstractions/security-state.service"; +import { ACCOUNT_SECURITY_STATE } from "../state/security-state.state"; + +export class DefaultSecurityStateService implements SecurityStateService { + constructor(protected stateProvider: StateProvider) {} + + // Emits the provided user's security state, or null if there is no security state present for the user. + accountSecurityState$(userId: UserId): Observable { + return this.stateProvider.getUserState$(ACCOUNT_SECURITY_STATE, userId); + } + + // Sets the security state for the provided user. + // This is not yet validated, and is only validated upon SDK initialization. + async setAccountSecurityState( + accountSecurityState: SignedSecurityState, + userId: UserId, + ): Promise { + await this.stateProvider.setUserState(ACCOUNT_SECURITY_STATE, accountSecurityState, userId); + } +} diff --git a/libs/common/src/key-management/security-state/state/security-state.state.ts b/libs/common/src/key-management/security-state/state/security-state.state.ts new file mode 100644 index 00000000000..e471ef17d76 --- /dev/null +++ b/libs/common/src/key-management/security-state/state/security-state.state.ts @@ -0,0 +1,12 @@ +import { CRYPTO_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; + +import { SignedSecurityState } from "../../types"; + +export const ACCOUNT_SECURITY_STATE = new UserKeyDefinition( + CRYPTO_DISK, + "accountSecurityState", + { + deserializer: (obj) => obj, + clearOn: ["logout"], + }, +); diff --git a/libs/common/src/key-management/types.ts b/libs/common/src/key-management/types.ts new file mode 100644 index 00000000000..df64a3ed342 --- /dev/null +++ b/libs/common/src/key-management/types.ts @@ -0,0 +1,30 @@ +import { Opaque } from "type-fest"; + +import { EncString, SignedSecurityState as SdkSignedSecurityState } from "@bitwarden/sdk-internal"; + +/** + * A private key, encrypted with a symmetric key. + */ +export type WrappedPrivateKey = Opaque; + +/** + * A public key, signed with the accounts signature key. + */ +export type SignedPublicKey = Opaque; +/** + * A public key in base64 encoded SPKI-DER + */ +export type UnsignedPublicKey = Opaque; + +/** + * A signature key encrypted with a symmetric key. + */ +export type WrappedSigningKey = Opaque; +/** + * A signature public key (verifying key) in base64 encoded CoseKey format + */ +export type VerifyingKey = Opaque; +/** + * A signed security state, encoded in base64. + */ +export type SignedSecurityState = Opaque; diff --git a/libs/common/src/models/response/profile.response.ts b/libs/common/src/models/response/profile.response.ts index a6982d7ed43..11aca73d5c9 100644 --- a/libs/common/src/models/response/profile.response.ts +++ b/libs/common/src/models/response/profile.response.ts @@ -1,3 +1,5 @@ +import { PrivateKeysResponseModel } from "@bitwarden/common/key-management/keys/response/private-keys.response"; + import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response"; import { ProfileProviderOrganizationResponse } from "../../admin-console/models/response/profile-provider-organization.response"; import { ProfileProviderResponse } from "../../admin-console/models/response/profile-provider.response"; @@ -18,7 +20,10 @@ export class ProfileResponse extends BaseResponse { key?: EncString; avatarColor: string; creationDate: string; + // Cleanup: Can be removed after moving to accountKeys privateKey: string; + // Cleanup: This should be non-optional after the server has been released for a while https://bitwarden.atlassian.net/browse/PM-21768 + accountKeys: PrivateKeysResponseModel | null = null; securityStamp: string; forcePasswordReset: boolean; usesKeyConnector: boolean; @@ -37,10 +42,16 @@ export class ProfileResponse extends BaseResponse { this.premiumFromOrganization = this.getResponseProperty("PremiumFromOrganization"); this.culture = this.getResponseProperty("Culture"); this.twoFactorEnabled = this.getResponseProperty("TwoFactorEnabled"); + const key = this.getResponseProperty("Key"); if (key) { this.key = new EncString(key); } + // Cleanup: This should be non-optional after the server has been released for a while https://bitwarden.atlassian.net/browse/PM-21768 + if (this.getResponseProperty("AccountKeys") != null) { + this.accountKeys = new PrivateKeysResponseModel(this.getResponseProperty("AccountKeys")); + } + this.avatarColor = this.getResponseProperty("AvatarColor"); this.creationDate = this.getResponseProperty("CreationDate"); this.privateKey = this.getResponseProperty("PrivateKey"); diff --git a/libs/common/src/platform/services/key-state/user-key.state.ts b/libs/common/src/platform/services/key-state/user-key.state.ts index 1b88554e53b..2416c211d6b 100644 --- a/libs/common/src/platform/services/key-state/user-key.state.ts +++ b/libs/common/src/platform/services/key-state/user-key.state.ts @@ -1,4 +1,5 @@ import { EncryptedString } from "../../../key-management/crypto/models/enc-string"; +import { WrappedSigningKey } from "../../../key-management/types"; import { UserKey } from "../../../types/key"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { CRYPTO_DISK, CRYPTO_MEMORY, UserKeyDefinition } from "../../state"; @@ -25,3 +26,12 @@ export const USER_KEY = new UserKeyDefinition(CRYPTO_MEMORY, "userKey", deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey, clearOn: ["logout", "lock"], }); + +export const USER_KEY_ENCRYPTED_SIGNING_KEY = new UserKeyDefinition( + CRYPTO_DISK, + "userSigningKey", + { + deserializer: (obj) => obj, + clearOn: ["logout"], + }, +); 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 7165e845885..4aee0d48e5a 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,7 +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"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.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"; @@ -18,6 +18,7 @@ import { AccountInfo } from "../../../auth/abstractions/account.service"; import { EncryptedString } from "../../../key-management/crypto/models/enc-string"; import { UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; +import { ConfigService } from "../../abstractions/config/config.service"; import { Environment, EnvironmentService } from "../../abstractions/environment.service"; import { PlatformUtilsService } from "../../abstractions/platform-utils.service"; import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory"; @@ -43,6 +44,7 @@ describe("DefaultSdkService", () => { let platformUtilsService!: MockProxy; let kdfConfigService!: MockProxy; let keyService!: MockProxy; + let securityStateService!: MockProxy; let configService!: MockProxy; let service!: DefaultSdkService; let accountService!: FakeAccountService; @@ -57,6 +59,7 @@ describe("DefaultSdkService", () => { platformUtilsService = mock(); kdfConfigService = mock(); keyService = mock(); + securityStateService = mock(); apiService = mock(); const mockUserId = Utils.newGuid() as UserId; accountService = mockAccountServiceWith(mockUserId); @@ -75,6 +78,7 @@ describe("DefaultSdkService", () => { accountService, kdfConfigService, keyService, + securityStateService, apiService, fakeStateProvider, configService, @@ -100,6 +104,8 @@ describe("DefaultSdkService", () => { .calledWith(userId) .mockReturnValue(of("private-key" as EncryptedString)); keyService.encryptedOrgKeys$.calledWith(userId).mockReturnValue(of({})); + keyService.userSigningKey$.calledWith(userId).mockReturnValue(of(null)); + securityStateService.accountSecurityState$.calledWith(userId).mockReturnValue(of(null)); }); describe("given no client override has been set for the user", () => { 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 ec57783e02f..6f9c9df761c 100644 --- a/libs/common/src/platform/services/sdk/default-sdk.service.ts +++ b/libs/common/src/platform/services/sdk/default-sdk.service.ts @@ -31,6 +31,8 @@ import { ApiService } from "../../../abstractions/api.service"; import { AccountInfo, AccountService } from "../../../auth/abstractions/account.service"; import { DeviceType } from "../../../enums/device-type.enum"; import { EncryptedString, EncString } from "../../../key-management/crypto/models/enc-string"; +import { SecurityStateService } from "../../../key-management/security-state/abstractions/security-state.service"; +import { SignedSecurityState, WrappedSigningKey } from "../../../key-management/types"; import { OrganizationId, UserId } from "../../../types/guid"; import { UserKey } from "../../../types/key"; import { Environment, EnvironmentService } from "../../abstractions/environment.service"; @@ -98,6 +100,7 @@ export class DefaultSdkService implements SdkService { private accountService: AccountService, private kdfConfigService: KdfConfigService, private keyService: KeyService, + private securityStateService: SecurityStateService, private apiService: ApiService, private stateProvider: StateProvider, private configService: ConfigService, @@ -160,10 +163,14 @@ export class DefaultSdkService implements SdkService { const privateKey$ = this.keyService .userEncryptedPrivateKey$(userId) .pipe(distinctUntilChanged()); + const signingKey$ = this.keyService.userSigningKey$(userId).pipe(distinctUntilChanged()); const userKey$ = this.keyService.userKey$(userId).pipe(distinctUntilChanged()); const orgKeys$ = this.keyService.encryptedOrgKeys$(userId).pipe( distinctUntilChanged(compareValues), // The upstream observable emits different objects with the same values ); + const securityState$ = this.securityStateService + .accountSecurityState$(userId) + .pipe(distinctUntilChanged(compareValues)); const client$ = combineLatest([ this.environmentService.getEnvironment$(userId), @@ -171,51 +178,57 @@ export class DefaultSdkService implements SdkService { kdfParams$, privateKey$, userKey$, + signingKey$, orgKeys$, + securityState$, SdkLoadService.Ready, // Makes sure we wait (once) for the SDK to be loaded ]).pipe( // switchMap is required to allow the clean-up logic to be executed when `combineLatest` emits a new value. - switchMap(([env, account, kdfParams, privateKey, userKey, orgKeys]) => { - // Create our own observable to be able to implement clean-up logic - return new Observable>((subscriber) => { - const createAndInitializeClient = async () => { - if (env == null || kdfParams == null || privateKey == null || userKey == null) { - return undefined; - } + switchMap( + ([env, account, kdfParams, privateKey, userKey, signingKey, orgKeys, securityState]) => { + // Create our own observable to be able to implement clean-up logic + return new Observable>((subscriber) => { + const createAndInitializeClient = async () => { + if (env == null || kdfParams == null || privateKey == null || userKey == null) { + return undefined; + } - const settings = this.toSettings(env); - const client = await this.sdkClientFactory.createSdkClient( - new JsTokenProvider(this.apiService, userId), - settings, - ); + const settings = this.toSettings(env); + const client = await this.sdkClientFactory.createSdkClient( + new JsTokenProvider(this.apiService, userId), + settings, + ); - await this.initializeClient( - userId, - client, - account, - kdfParams, - privateKey, - userKey, - orgKeys, - ); + await this.initializeClient( + userId, + client, + account, + kdfParams, + privateKey, + userKey, + signingKey, + securityState, + orgKeys, + ); - return client; - }; + return client; + }; - let client: Rc | undefined; - createAndInitializeClient() - .then((c) => { - client = c === undefined ? undefined : new Rc(c); + let client: Rc | undefined; + createAndInitializeClient() + .then((c) => { + client = c === undefined ? undefined : new Rc(c); - subscriber.next(client); - }) - .catch((e) => { - subscriber.error(e); - }); + subscriber.next(client); + }) + .catch((e) => { + subscriber.error(e); + }); - return () => client?.markForDisposal(); - }); - }), + return () => client?.markForDisposal(); + }); + }, + ), tap({ finalize: () => this.sdkClientCache.delete(userId) }), shareReplay({ refCount: true, bufferSize: 1 }), ); @@ -231,6 +244,8 @@ export class DefaultSdkService implements SdkService { kdfParams: KdfConfig, privateKey: EncryptedString, userKey: UserKey, + signingKey: WrappedSigningKey | null, + securityState: SignedSecurityState | null, orgKeys: Record, ) { await client.crypto().initialize_user_crypto({ @@ -248,8 +263,8 @@ export class DefaultSdkService implements SdkService { }, }, privateKey, - signingKey: undefined, - securityState: undefined, + signingKey: signingKey || undefined, + securityState: securityState || undefined, }); // We initialize the org crypto even if the org_keys are diff --git a/libs/common/src/platform/sync/default-sync.service.spec.ts b/libs/common/src/platform/sync/default-sync.service.spec.ts index 193a5a2d2dd..f60b42ce450 100644 --- a/libs/common/src/platform/sync/default-sync.service.spec.ts +++ b/libs/common/src/platform/sync/default-sync.service.spec.ts @@ -11,6 +11,8 @@ import { UserDecryptionOptions, UserDecryptionOptionsServiceAbstraction, } from "@bitwarden/auth/common"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.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, PBKDF2KdfConfig } from "@bitwarden/key-management"; @@ -72,6 +74,7 @@ describe("DefaultSyncService", () => { let tokenService: MockProxy; let authService: MockProxy; let stateProvider: MockProxy; + let securityStateService: MockProxy; let sut: DefaultSyncService; @@ -101,6 +104,7 @@ describe("DefaultSyncService", () => { tokenService = mock(); authService = mock(); stateProvider = mock(); + securityStateService = mock(); sut = new DefaultSyncService( masterPasswordAbstraction, @@ -127,6 +131,7 @@ describe("DefaultSyncService", () => { tokenService, authService, stateProvider, + securityStateService, ); }); @@ -155,6 +160,142 @@ describe("DefaultSyncService", () => { stateProvider.getUser.mockReturnValue(mock()); }); + it("sets the correct keys for a V1 user with old response model", async () => { + const v1Profile = { + id: user1, + key: "encryptedUserKey", + privateKey: "privateKey", + providers: [] as any[], + organizations: [] as any[], + providerOrganizations: [] as any[], + avatarColor: "#fff", + securityStamp: "stamp", + emailVerified: true, + verifyDevices: false, + premiumPersonally: false, + premiumFromOrganization: false, + usesKeyConnector: false, + }; + apiService.getSync.mockResolvedValue( + new SyncResponse({ + profile: v1Profile, + folders: [], + collections: [], + ciphers: [], + sends: [], + domains: [], + policies: [], + }), + ); + await sut.fullSync(true); + expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + new EncString("encryptedUserKey"), + user1, + ); + expect(keyService.setPrivateKey).toHaveBeenCalledWith("privateKey", user1); + expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); + expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); + }); + + it("sets the correct keys for a V1 user", async () => { + const v1Profile = { + id: user1, + key: "encryptedUserKey", + privateKey: "privateKey", + providers: [] as any[], + organizations: [] as any[], + providerOrganizations: [] as any[], + avatarColor: "#fff", + securityStamp: "stamp", + emailVerified: true, + verifyDevices: false, + premiumPersonally: false, + premiumFromOrganization: false, + usesKeyConnector: false, + accountKeys: { + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: "wrappedPrivateKey", + publicKey: "publicKey", + }, + }, + }; + apiService.getSync.mockResolvedValue( + new SyncResponse({ + profile: v1Profile, + folders: [], + collections: [], + ciphers: [], + sends: [], + domains: [], + policies: [], + }), + ); + await sut.fullSync(true); + expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + new EncString("encryptedUserKey"), + user1, + ); + expect(keyService.setPrivateKey).toHaveBeenCalledWith("wrappedPrivateKey", user1); + expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); + expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); + }); + + it("sets the correct keys for a V2 user", async () => { + const v2Profile = { + id: user1, + key: "encryptedUserKey", + providers: [] as unknown[], + organizations: [] as unknown[], + providerOrganizations: [] as unknown[], + avatarColor: "#fff", + securityStamp: "stamp", + emailVerified: true, + verifyDevices: false, + premiumPersonally: false, + premiumFromOrganization: false, + usesKeyConnector: false, + privateKey: "wrappedPrivateKey", + accountKeys: { + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: "wrappedPrivateKey", + publicKey: "publicKey", + signedPublicKey: "signedPublicKey", + }, + signatureKeyPair: { + wrappedSigningKey: "wrappedSigningKey", + verifyingKey: "verifyingKey", + }, + securityState: { + securityState: "securityState", + }, + }, + }; + apiService.getSync.mockResolvedValue( + new SyncResponse({ + profile: v2Profile, + folders: [], + collections: [], + ciphers: [], + sends: [], + domains: [], + policies: [], + }), + ); + await sut.fullSync(true); + expect(masterPasswordAbstraction.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith( + new EncString("encryptedUserKey"), + user1, + ); + expect(keyService.setPrivateKey).toHaveBeenCalledWith("wrappedPrivateKey", user1); + expect(keyService.setUserSigningKey).toHaveBeenCalledWith("wrappedSigningKey", user1); + expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith( + "securityState", + user1, + ); + expect(keyService.setProviderKeys).toHaveBeenCalledWith([], user1); + expect(keyService.setOrgKeys).toHaveBeenCalledWith([], [], user1); + }); + it("does a token refresh when option missing from options", async () => { await sut.fullSync(true, { allowThrowOnError: false }); diff --git a/libs/common/src/platform/sync/default-sync.service.ts b/libs/common/src/platform/sync/default-sync.service.ts index a02d602dbf0..d5fa2d0ae68 100644 --- a/libs/common/src/platform/sync/default-sync.service.ts +++ b/libs/common/src/platform/sync/default-sync.service.ts @@ -10,6 +10,7 @@ import { CollectionService, } from "@bitwarden/admin-console/common"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service"; // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; @@ -98,6 +99,7 @@ export class DefaultSyncService extends CoreSyncService { tokenService: TokenService, authService: AuthService, stateProvider: StateProvider, + private securityStateService: SecurityStateService, ) { super( tokenService, @@ -233,13 +235,34 @@ export class DefaultSyncService extends CoreSyncService { if (response?.key) { await this.masterPasswordService.setMasterKeyEncryptedUserKey(response.key, response.id); } - await this.keyService.setPrivateKey(response.privateKey, response.id); + + // Cleanup: Only the first branch should be kept after the server always returns accountKeys https://bitwarden.atlassian.net/browse/PM-21768 + if (response.accountKeys != null) { + await this.keyService.setPrivateKey( + response.accountKeys.publicKeyEncryptionKeyPair.wrappedPrivateKey, + response.id, + ); + if (response.accountKeys.signatureKeyPair !== null) { + // User is V2 user + await this.keyService.setUserSigningKey( + response.accountKeys.signatureKeyPair.wrappedSigningKey, + response.id, + ); + await this.securityStateService.setAccountSecurityState( + response.accountKeys.securityState.securityState, + response.id, + ); + } + } else { + await this.keyService.setPrivateKey(response.privateKey, response.id); + } await this.keyService.setProviderKeys(response.providers, response.id); await this.keyService.setOrgKeys( response.organizations, response.providerOrganizations, response.id, ); + await this.avatarService.setSyncAvatarColor(response.id, response.avatarColor); await this.tokenService.setSecurityStamp(response.securityStamp, response.id); await this.accountService.setAccountEmailVerified(response.id, response.emailVerified); diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index e9b0ae7b3b8..d465aa97924 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -1,5 +1,7 @@ import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; +import { emptyGuid, UserId } from "@bitwarden/common/types/guid"; // 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"; @@ -97,6 +99,7 @@ describe("Send", () => { const text = mock(); text.decrypt.mockResolvedValue("textView" as any); const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const userId = emptyGuid as UserId; const send = new Send(); send.id = "id"; @@ -120,11 +123,11 @@ describe("Send", () => { .calledWith(send.key, userKey) .mockResolvedValue(makeStaticByteArray(32)); keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); - keyService.getUserKey.mockResolvedValue(userKey); + keyService.userKey$.calledWith(userId).mockReturnValue(of(userKey)); (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); - const view = await send.decrypt(); + const view = await send.decrypt(userId); expect(text.decrypt).toHaveBeenNthCalledWith(1, "cryptoKey"); expect(send.name.decrypt).toHaveBeenNthCalledWith( diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index 48057aedd2d..48129d4314a 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -1,7 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { firstValueFrom } from "rxjs"; import { Jsonify } from "type-fest"; +import { UserId } from "@bitwarden/common/types/guid"; + import { EncString } from "../../../../key-management/crypto/models/enc-string"; import { Utils } from "../../../../platform/misc/utils"; import Domain from "../../../../platform/models/domain/domain-base"; @@ -73,22 +76,18 @@ export class Send extends Domain { } } - async decrypt(): Promise { - const model = new SendView(this); + async decrypt(userId: UserId): Promise { + if (!userId) { + throw new Error("User ID must not be null or undefined"); + } + const model = new SendView(this); const keyService = Utils.getContainerService().getKeyService(); const encryptService = Utils.getContainerService().getEncryptService(); - - try { - const sendKeyEncryptionKey = await keyService.getUserKey(); - // model.key is a seed used to derive a key, not a SymmetricCryptoKey - model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey); - model.cryptoKey = await keyService.makeSendKey(model.key); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { - // TODO: error? - } + const sendKeyEncryptionKey = await firstValueFrom(keyService.userKey$(userId)); + // model.key is a seed used to derive a key, not a SymmetricCryptoKey + model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey); + model.cryptoKey = await keyService.makeSendKey(model.key); await this.decryptObj(this, model, ["name", "notes"], null, model.cryptoKey); diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index 8b080089c3c..96fb2f43c88 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -86,6 +86,7 @@ describe("SendService", () => { decryptedState.nextState([testSendViewData("1", "Test Send")]); sendService = new SendService( + accountService, keyService, i18nService, keyGenerationService, diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 2664b0d4351..810dbc05a2f 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Observable, concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.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 { PBKDF2KdfConfig, KeyService } from "@bitwarden/key-management"; @@ -35,12 +36,16 @@ export class SendService implements InternalSendServiceAbstraction { map(([, record]) => Object.values(record || {}).map((data) => new Send(data))), ); sendViews$ = this.stateProvider.encryptedState$.pipe( - concatMap(([, record]) => - this.decryptSends(Object.values(record || {}).map((data) => new Send(data))), + concatMap(([userId, record]) => + this.decryptSends( + Object.values(record || {}).map((data) => new Send(data)), + userId, + ), ), ); constructor( + private accountService: AccountService, private keyService: KeyService, private i18nService: I18nService, private keyGenerationService: KeyGenerationService, @@ -89,8 +94,9 @@ export class SendService implements InternalSendServiceAbstraction { ); send.password = passwordKey.keyB64; } + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; if (userKey == null) { - userKey = await this.keyService.getUserKey(); + userKey = await firstValueFrom(this.keyService.userKey$(userId)); } // Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey send.key = await this.encryptService.encryptBytes(model.key, userKey); @@ -111,11 +117,12 @@ export class SendService implements InternalSendServiceAbstraction { model.file.fileName, file, model.cryptoKey, + userId, ); send.file.fileName = name; fileData = data; } else { - fileData = await this.parseFile(send, file, model.cryptoKey); + fileData = await this.parseFile(send, file, model.cryptoKey, userId); } } } @@ -208,6 +215,9 @@ export class SendService implements InternalSendServiceAbstraction { } async getAllDecryptedFromState(userId: UserId): Promise { + if (!userId) { + throw new Error("User ID must not be null or undefined"); + } let decSends = await this.stateProvider.getDecryptedSends(); if (decSends != null) { return decSends; @@ -222,7 +232,7 @@ export class SendService implements InternalSendServiceAbstraction { const promises: Promise[] = []; const sends = await this.getAll(); sends.forEach((send) => { - promises.push(send.decrypt().then((f) => decSends.push(f))); + promises.push(send.decrypt(userId).then((f) => decSends.push(f))); }); await Promise.all(promises); @@ -311,7 +321,12 @@ export class SendService implements InternalSendServiceAbstraction { return requests; } - private parseFile(send: Send, file: File, key: SymmetricCryptoKey): Promise { + private parseFile( + send: Send, + file: File, + key: SymmetricCryptoKey, + userId: UserId, + ): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsArrayBuffer(file); @@ -321,6 +336,7 @@ export class SendService implements InternalSendServiceAbstraction { file.name, evt.target.result as ArrayBuffer, key, + userId, ); send.file.fileName = name; resolve(data); @@ -338,17 +354,18 @@ export class SendService implements InternalSendServiceAbstraction { fileName: string, data: ArrayBuffer, key: SymmetricCryptoKey, + userId: UserId, ): Promise<[EncString, EncArrayBuffer]> { if (key == null) { - key = await this.keyService.getUserKey(); + key = await firstValueFrom(this.keyService.userKey$(userId)); } const encFileName = await this.encryptService.encryptString(fileName, key); const encFileData = await this.encryptService.encryptFileData(new Uint8Array(data), key); return [encFileName, encFileData]; } - private async decryptSends(sends: Send[]) { - const decryptSendPromises = sends.map((s) => s.decrypt()); + private async decryptSends(sends: Send[], userId: UserId) { + const decryptSendPromises = sends.map((s) => s.decrypt(userId)); const decryptedSends = await Promise.all(decryptSendPromises); decryptedSends.sort(Utils.getSortFunction(this.i18nService, "name")); diff --git a/libs/common/src/types/key.ts b/libs/common/src/types/key.ts index 8984452e701..ca56deb2fb1 100644 --- a/libs/common/src/types/key.ts +++ b/libs/common/src/types/key.ts @@ -1,5 +1,6 @@ import { Opaque } from "type-fest"; +import { UnsignedPublicKey } from "../key-management/types"; import { SymmetricCryptoKey } from "../platform/models/domain/symmetric-crypto-key"; // symmetric keys @@ -15,4 +16,4 @@ export type CipherKey = Opaque; // asymmetric keys export type UserPrivateKey = Opaque; -export type UserPublicKey = Opaque; +export type UserPublicKey = Opaque; diff --git a/libs/common/src/vault/abstractions/cipher-archive.service.ts b/libs/common/src/vault/abstractions/cipher-archive.service.ts index cb6c38ddf67..d33fc5e7cc7 100644 --- a/libs/common/src/vault/abstractions/cipher-archive.service.ts +++ b/libs/common/src/vault/abstractions/cipher-archive.service.ts @@ -4,6 +4,7 @@ import { CipherId, UserId } from "@bitwarden/common/types/guid"; import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; export abstract class CipherArchiveService { + abstract hasArchiveFlagEnabled$(): Observable; abstract archivedCiphers$(userId: UserId): Observable; abstract userCanArchive$(userId: UserId): Observable; abstract showArchiveVault$(userId: UserId): Observable; diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index b3cb43dcc93..9b1d8096fc7 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -819,6 +819,28 @@ describe("Cipher Service", () => { }); }); + describe("softDelete", () => { + it("clears archivedDate when soft deleting", async () => { + const cipherId = "cipher-id-1" as CipherId; + const archivedCipher = { + ...cipherData, + id: cipherId, + archivedDate: "2024-01-01T12:00:00.000Z", + } as CipherData; + + const ciphers = { [cipherId]: archivedCipher } as Record; + stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).nextState(ciphers); + + await cipherService.softDelete(cipherId, mockUserId); + + const result = await firstValueFrom( + stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).state$, + ); + expect(result[cipherId].archivedDate).toBeNull(); + expect(result[cipherId].deletedDate).toBeDefined(); + }); + }); + describe("replace (no upsert)", () => { // In order to set up initial state we need to manually update the encrypted state // which will result in an emission. All tests will have this baseline emission. diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 809f7627e19..9799cb7edfa 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -17,6 +17,7 @@ import { MessageSender } from "@bitwarden/common/platform/messaging"; // 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"; +import { CipherListView } from "@bitwarden/sdk-internal"; import { ApiService } from "../../abstractions/api.service"; import { AccountService } from "../../auth/abstractions/account.service"; @@ -158,9 +159,9 @@ export class CipherService implements CipherServiceAbstraction { ), ), switchMap(async (ciphers) => { - const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId); + const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false); await this.setFailedDecryptedCiphers(failures, userId); - return decrypted.sort(this.getLocaleSortingFunction()); + return decrypted; }), ); }), @@ -489,7 +490,7 @@ export class CipherService implements CipherServiceAbstraction { if (await this.configService.getFeatureFlag(FeatureFlag.PM19941MigrateCipherDomainToSdk)) { const decryptStartTime = performance.now(); - const result = await this.decryptCiphersWithSdk(ciphers, userId); + const result = await this.decryptCiphersWithSdk(ciphers, userId, true); this.logService.measure(decryptStartTime, "Vault", "CipherService", "decrypt complete", [ ["Items", ciphers.length], @@ -1421,6 +1422,7 @@ export class CipherService implements CipherServiceAbstraction { return; } ciphers[cipherId].deletedDate = new Date().toISOString(); + ciphers[cipherId].archivedDate = null; }; if (typeof id === "string") { @@ -2067,21 +2069,50 @@ export class CipherService implements CipherServiceAbstraction { } /** - * Decrypts the provided ciphers using the SDK. - * @param ciphers The ciphers to decrypt. - * @param userId The user ID to use for decryption. - * @returns The decrypted ciphers. + * Decrypts the provided ciphers using the SDK with full CipherView decryption. + * @param ciphers The encrypted ciphers to decrypt. + * @param userId The user ID to use for decryption keys. + * @param fullDecryption When true, returns full CipherView objects with all fields decrypted. + * @returns A tuple containing: + * - Array of fully decrypted CipherView objects, sorted by locale + * - Array of CipherView objects that failed to decrypt (marked with decryptionFailure flag) * @private */ private async decryptCiphersWithSdk( ciphers: Cipher[], userId: UserId, - ): Promise<[CipherView[], CipherView[]]> { + fullDecryption: true, + ): Promise<[CipherView[], CipherView[]]>; + /** + * Decrypts the provided ciphers using the SDK with lightweight CipherListView decryption. + * @param ciphers The encrypted ciphers to decrypt. + * @param userId The user ID to use for decryption keys. + * @param fullDecryption When false, returns lightweight CipherListView objects for better performance. + * @returns A tuple containing: + * - Array of lightweight CipherListView objects, sorted by locale + * - Array of CipherView objects that failed to decrypt (marked with decryptionFailure flag) + * @private + */ + private async decryptCiphersWithSdk( + ciphers: Cipher[], + userId: UserId, + fullDecryption: false, + ): Promise<[CipherListView[], CipherView[]]>; + + private async decryptCiphersWithSdk( + ciphers: Cipher[], + userId: UserId, + fullDecryption: boolean = true, + ): Promise<[CipherViewLike[], CipherView[]]> { const [decrypted, failures] = await this.cipherEncryptionService.decryptManyWithFailures( ciphers, userId, ); - const decryptedViews = await Promise.all(decrypted.map((c) => this.getFullCipherView(c))); + + const decryptedViews = fullDecryption + ? await Promise.all(decrypted.map((c) => this.getFullCipherView(c))) + : decrypted; + const failedViews = failures.map((c) => { const cipher_view = new CipherView(c); cipher_view.name = "[error: cannot decrypt]"; diff --git a/libs/common/src/vault/services/default-cipher-archive.service.ts b/libs/common/src/vault/services/default-cipher-archive.service.ts index 5c627d687b2..a56a22474a3 100644 --- a/libs/common/src/vault/services/default-cipher-archive.service.ts +++ b/libs/common/src/vault/services/default-cipher-archive.service.ts @@ -27,6 +27,10 @@ export class DefaultCipherArchiveService implements CipherArchiveService { private configService: ConfigService, ) {} + hasArchiveFlagEnabled$(): Observable { + return this.configService.getFeatureFlag$(FeatureFlag.PM19148_InnovationArchive); + } + /** * Observable that contains the list of ciphers that have been archived. */ diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.html b/libs/components/src/anon-layout/anon-layout-wrapper.component.html index 3509e4dcdb0..b08418c39a1 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.html +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.html @@ -6,6 +6,7 @@ [maxWidth]="maxWidth" [hideCardWrapper]="hideCardWrapper" [hideIcon]="hideIcon" + [hideBackgroundIllustration]="hideBackgroundIllustration" > diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts index 13c2e727477..5785609189c 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -44,6 +44,10 @@ export interface AnonLayoutWrapperData { * Hide the card that wraps the default content. Defaults to false. */ hideCardWrapper?: boolean; + /** + * Hides the background illustration. Defaults to false. + */ + hideBackgroundIllustration?: boolean; } @Component({ @@ -60,6 +64,7 @@ export class AnonLayoutWrapperComponent implements OnInit { protected maxWidth?: AnonLayoutMaxWidth | null; protected hideCardWrapper?: boolean | null; protected hideIcon?: boolean | null; + protected hideBackgroundIllustration?: boolean | null; constructor( private router: Router, @@ -117,6 +122,7 @@ export class AnonLayoutWrapperComponent implements OnInit { this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]); this.maxWidth = firstChildRouteData["maxWidth"]; this.hideCardWrapper = Boolean(firstChildRouteData["hideCardWrapper"]); + this.hideBackgroundIllustration = Boolean(firstChildRouteData["hideBackgroundIllustration"]); } private listenForServiceDataChanges() { @@ -157,6 +163,10 @@ export class AnonLayoutWrapperComponent implements OnInit { this.hideCardWrapper = data.hideCardWrapper; } + if (data.hideBackgroundIllustration !== undefined) { + this.hideBackgroundIllustration = data.hideBackgroundIllustration; + } + if (data.hideIcon !== undefined) { this.hideIcon = data.hideIcon; } @@ -188,5 +198,6 @@ export class AnonLayoutWrapperComponent implements OnInit { this.maxWidth = null; this.hideCardWrapper = null; this.hideIcon = null; + this.hideBackgroundIllustration = null; } } diff --git a/libs/components/src/anon-layout/anon-layout.component.html b/libs/components/src/anon-layout/anon-layout.component.html index c66647c482d..84ad8742051 100644 --- a/libs/components/src/anon-layout/anon-layout.component.html +++ b/libs/components/src/anon-layout/anon-layout.component.html @@ -68,16 +68,18 @@ -
- -
-
- -
+ @if (!hideBackgroundIllustration()) { +
+ +
+
+ +
+ } diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index c0beb5bce56..9decb7cb4f7 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -51,6 +51,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges { readonly hideFooter = input(false); readonly hideIcon = input(false); readonly hideCardWrapper = input(false); + readonly hideBackgroundIllustration = input(false); /** * Max width of the anon layout title, subtitle, and content areas. diff --git a/libs/components/src/anon-layout/anon-layout.stories.ts b/libs/components/src/anon-layout/anon-layout.stories.ts index 15cce39d8b7..3593cb4f30e 100644 --- a/libs/components/src/anon-layout/anon-layout.stories.ts +++ b/libs/components/src/anon-layout/anon-layout.stories.ts @@ -79,6 +79,7 @@ export default { [hideIcon]="hideIcon" [hideLogo]="hideLogo" [hideFooter]="hideFooter" + [hideBackgroundIllustration]="hideBackgroundIllustration" >
Thin Content
@@ -125,6 +126,7 @@ export default { hideIcon: { control: "boolean" }, hideLogo: { control: "boolean" }, hideFooter: { control: "boolean" }, + hideBackgroundIllustration: { control: "boolean" }, contentLength: { control: "radio", @@ -145,6 +147,7 @@ export default { hideIcon: false, hideLogo: false, hideFooter: false, + hideBackgroundIllustration: false, contentLength: "normal", showSecondary: false, }, @@ -221,6 +224,10 @@ export const NoFooter: Story = { args: { hideFooter: true }, }; +export const NoBackgroundIllustration: Story = { + args: { hideBackgroundIllustration: true }, +}; + export const ReadonlyHostname: Story = { args: { showReadonlyHostname: true }, }; @@ -234,5 +241,6 @@ export const MinimalState: Story = { hideIcon: true, hideLogo: true, hideFooter: true, + hideBackgroundIllustration: true, }, }; diff --git a/libs/components/src/callout/callout.component.ts b/libs/components/src/callout/callout.component.ts index 62321a34d91..c15bc132035 100644 --- a/libs/components/src/callout/callout.component.ts +++ b/libs/components/src/callout/callout.component.ts @@ -36,11 +36,17 @@ let nextId = 0; export class CalloutComponent { readonly type = input("info"); readonly icon = input(); - readonly title = input(); + readonly title = input(); readonly useAlertRole = input(false); - readonly iconComputed = computed(() => this.icon() ?? defaultIcon[this.type()]); + readonly iconComputed = computed(() => + this.icon() === undefined ? defaultIcon[this.type()] : this.icon(), + ); readonly titleComputed = computed(() => { const title = this.title(); + if (title === null) { + return undefined; + } + const type = this.type(); if (title == null && defaultI18n[type] != null) { return this.i18nService.t(defaultI18n[type]); diff --git a/libs/components/src/icon-tile/icon-tile.component.html b/libs/components/src/icon-tile/icon-tile.component.html new file mode 100644 index 00000000000..2dff588243e --- /dev/null +++ b/libs/components/src/icon-tile/icon-tile.component.html @@ -0,0 +1,7 @@ +
+ +
diff --git a/libs/components/src/icon-tile/icon-tile.component.ts b/libs/components/src/icon-tile/icon-tile.component.ts new file mode 100644 index 00000000000..54e92f9f004 --- /dev/null +++ b/libs/components/src/icon-tile/icon-tile.component.ts @@ -0,0 +1,111 @@ +import { NgClass } from "@angular/common"; +import { Component, computed, input } from "@angular/core"; + +import { BitwardenIcon } from "../shared/icon"; + +export type IconTileVariant = "primary" | "success" | "warning" | "danger" | "muted"; + +export type IconTileSize = "small" | "default" | "large"; + +export type IconTileShape = "square" | "circle"; + +const variantStyles: Record = { + primary: ["tw-bg-primary-100", "tw-text-primary-700"], + success: ["tw-bg-success-100", "tw-text-success-700"], + warning: ["tw-bg-warning-100", "tw-text-warning-700"], + danger: ["tw-bg-danger-100", "tw-text-danger-700"], + muted: ["tw-bg-secondary-100", "tw-text-secondary-700"], +}; + +const sizeStyles: Record = { + small: { + container: ["tw-w-6", "tw-h-6"], + icon: ["tw-text-sm"], + }, + default: { + container: ["tw-w-8", "tw-h-8"], + icon: ["tw-text-base"], + }, + large: { + container: ["tw-w-10", "tw-h-10"], + icon: ["tw-text-lg"], + }, +}; + +const shapeStyles: Record> = { + square: { + small: ["tw-rounded"], + default: ["tw-rounded-md"], + large: ["tw-rounded-lg"], + }, + circle: { + small: ["tw-rounded-full"], + default: ["tw-rounded-full"], + large: ["tw-rounded-full"], + }, +}; + +/** + * Icon tiles are static containers that display an icon with a colored background. + * They are similar to icon buttons but are not interactive and are used for visual + * indicators, status representations, or decorative elements. + * + * Use icon tiles to: + * - Display status or category indicators + * - Represent different types of content + * - Create visual hierarchy in lists or cards + * - Show app or service icons in a consistent format + */ +@Component({ + selector: "bit-icon-tile", + templateUrl: "icon-tile.component.html", + imports: [NgClass], +}) +export class IconTileComponent { + /** + * The BWI icon name + */ + readonly icon = input.required(); + + /** + * The visual theme of the icon tile + */ + readonly variant = input("primary"); + + /** + * The size of the icon tile + */ + readonly size = input("default"); + + /** + * The shape of the icon tile + */ + readonly shape = input("square"); + + /** + * Optional aria-label for accessibility when the icon has semantic meaning + */ + readonly ariaLabel = input(); + + protected readonly containerClasses = computed(() => { + const variant = this.variant(); + const size = this.size(); + const shape = this.shape(); + + return [ + "tw-inline-flex", + "tw-items-center", + "tw-justify-center", + "tw-flex-shrink-0", + ...variantStyles[variant], + ...sizeStyles[size].container, + ...shapeStyles[shape][size], + ]; + }); + + protected readonly iconClasses = computed(() => { + const size = this.size(); + + return ["bwi", this.icon(), ...sizeStyles[size].icon]; + }); +} diff --git a/libs/components/src/icon-tile/icon-tile.stories.ts b/libs/components/src/icon-tile/icon-tile.stories.ts new file mode 100644 index 00000000000..2daa0d4289a --- /dev/null +++ b/libs/components/src/icon-tile/icon-tile.stories.ts @@ -0,0 +1,114 @@ +import { Meta, StoryObj } from "@storybook/angular"; + +import { BITWARDEN_ICONS } from "../shared/icon"; + +import { IconTileComponent } from "./icon-tile.component"; + +export default { + title: "Component Library/Icon Tile", + component: IconTileComponent, + args: { + icon: "bwi-star", + variant: "primary", + size: "default", + shape: "square", + }, + argTypes: { + variant: { + options: ["primary", "success", "warning", "danger", "muted"], + control: { type: "select" }, + }, + size: { + options: ["small", "default", "large"], + control: { type: "select" }, + }, + shape: { + options: ["square", "circle"], + control: { type: "select" }, + }, + icon: { + options: BITWARDEN_ICONS, + control: { type: "select" }, + }, + ariaLabel: { + control: { type: "text" }, + }, + }, + parameters: { + design: { + type: "figma", + url: "https://atlassian.design/components/icon/icon-tile/examples", + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const AllVariants: Story = { + render: () => ({ + template: ` +
+
+ + Primary +
+
+ + Success +
+
+ + Warning +
+
+ + Danger +
+
+ + Muted +
+
+ `, + }), +}; + +export const AllSizes: Story = { + render: () => ({ + template: ` +
+
+ + Small +
+
+ + Default +
+
+ + Large +
+
+ `, + }), +}; + +export const AllShapes: Story = { + render: () => ({ + template: ` +
+
+ + Square +
+
+ + Circle +
+
+ `, + }), +}; diff --git a/libs/components/src/icon-tile/index.ts b/libs/components/src/icon-tile/index.ts new file mode 100644 index 00000000000..415c9e478cc --- /dev/null +++ b/libs/components/src/icon-tile/index.ts @@ -0,0 +1 @@ +export * from "./icon-tile.component"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index d231048563c..2384696b770 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -21,6 +21,7 @@ export * from "./drawer"; export * from "./form-field"; export * from "./icon-button"; export * from "./icon"; +export * from "./icon-tile"; export * from "./input"; export * from "./item"; export * from "./layout"; diff --git a/libs/components/src/navigation/nav-group.stories.ts b/libs/components/src/navigation/nav-group.stories.ts index 7aa9279e0e2..8bfd8007ac0 100644 --- a/libs/components/src/navigation/nav-group.stories.ts +++ b/libs/components/src/navigation/nav-group.stories.ts @@ -64,7 +64,8 @@ export default { type: "figma", url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=16329-40145&t=b5tDKylm5sWm2yKo-4", }, - chromatic: { viewports: [640, 1280] }, + // remove disableSnapshots in CL-890 + chromatic: { viewports: [640, 1280], disableSnapshot: true }, }, } as Meta; diff --git a/libs/components/src/navigation/nav-item.stories.ts b/libs/components/src/navigation/nav-item.stories.ts index 7e88e16ccac..56f99502710 100644 --- a/libs/components/src/navigation/nav-item.stories.ts +++ b/libs/components/src/navigation/nav-item.stories.ts @@ -42,7 +42,8 @@ export default { type: "figma", url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=16329-40145&t=b5tDKylm5sWm2yKo-4", }, - chromatic: { viewports: [640, 1280] }, + // remove disableSnapshots in CL-890 + chromatic: { viewports: [640, 1280], disableSnapshot: true }, }, } as Meta; diff --git a/libs/components/src/shared/icon.ts b/libs/components/src/shared/icon.ts new file mode 100644 index 00000000000..6830ce49d7c --- /dev/null +++ b/libs/components/src/shared/icon.ts @@ -0,0 +1,110 @@ +/** + * Array of available Bitwarden Web Icons (bwi) font names. + * These correspond to the actual icon names defined in the bwi-font. + * This array serves as the single source of truth for all available icons. + */ +export const BITWARDEN_ICONS = [ + "bwi-angle-down", + "bwi-angle-left", + "bwi-angle-right", + "bwi-angle-up", + "bwi-archive", + "bwi-bell", + "bwi-billing", + "bwi-bitcoin", + "bwi-browser", + "bwi-browser-alt", + "bwi-brush", + "bwi-bug", + "bwi-business", + "bwi-camera", + "bwi-check", + "bwi-check-circle", + "bwi-cli", + "bwi-clock", + "bwi-clone", + "bwi-close", + "bwi-cog", + "bwi-cog-f", + "bwi-collection", + "bwi-collection-shared", + "bwi-credit-card", + "bwi-dashboard", + "bwi-desktop", + "bwi-dollar", + "bwi-down-solid", + "bwi-download", + "bwi-drag-and-drop", + "bwi-ellipsis-h", + "bwi-ellipsis-v", + "bwi-envelope", + "bwi-error", + "bwi-exclamation-triangle", + "bwi-external-link", + "bwi-eye", + "bwi-eye-slash", + "bwi-family", + "bwi-file", + "bwi-file-text", + "bwi-files", + "bwi-filter", + "bwi-folder", + "bwi-generate", + "bwi-globe", + "bwi-hashtag", + "bwi-id-card", + "bwi-import", + "bwi-info-circle", + "bwi-key", + "bwi-list", + "bwi-list-alt", + "bwi-lock", + "bwi-lock-encrypted", + "bwi-lock-f", + "bwi-minus-circle", + "bwi-mobile", + "bwi-msp", + "bwi-numbered-list", + "bwi-paperclip", + "bwi-passkey", + "bwi-paypal", + "bwi-pencil", + "bwi-pencil-square", + "bwi-plus", + "bwi-plus-circle", + "bwi-popout", + "bwi-provider", + "bwi-puzzle", + "bwi-question-circle", + "bwi-refresh", + "bwi-search", + "bwi-send", + "bwi-share", + "bwi-shield", + "bwi-sign-in", + "bwi-sign-out", + "bwi-sliders", + "bwi-spinner", + "bwi-star", + "bwi-star-f", + "bwi-sticky-note", + "bwi-tag", + "bwi-trash", + "bwi-undo", + "bwi-universal-access", + "bwi-unlock", + "bwi-up-down-btn", + "bwi-up-solid", + "bwi-user", + "bwi-user-monitor", + "bwi-users", + "bwi-vault", + "bwi-wireless", + "bwi-wrench", +] as const; + +/** + * Type-safe icon names derived from the BITWARDEN_ICONS array. + * This ensures type safety while allowing runtime iteration and validation. + */ +export type BitwardenIcon = (typeof BITWARDEN_ICONS)[number]; diff --git a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts index 14a16211deb..76965a364eb 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts @@ -88,7 +88,7 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { for (const c of results.items) { const cipher = CipherWithIdExport.toDomain(c); - // reset ids incase they were set for some reason + // reset ids in case they were set for some reason cipher.id = null; cipher.organizationId = this.organizationId; cipher.collectionIds = null; @@ -131,7 +131,7 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { results.items.forEach((c) => { const cipher = CipherWithIdExport.toView(c); - // reset ids incase they were set for some reason + // reset ids in case they were set for some reason cipher.id = null; cipher.organizationId = null; cipher.collectionIds = null; diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts index dfdcef51735..46c8ef79769 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts @@ -9,7 +9,6 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { emptyGuid, OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey, UserKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { newGuid } from "@bitwarden/guid"; import { KdfType, KeyService } from "@bitwarden/key-management"; import { UserId } from "@bitwarden/user-core"; @@ -41,7 +40,7 @@ describe("BitwardenPasswordProtectedImporter", () => { accountService = mock(); accountService.activeAccount$ = of({ - id: newGuid() as UserId, + id: emptyGuid as UserId, email: "test@example.com", emailVerified: true, name: "Test User", @@ -52,8 +51,8 @@ describe("BitwardenPasswordProtectedImporter", () => { The key values below are never read, empty objects are cast as types for compilation type checking only. Tests specific to key contents are in key-service.spec.ts */ - const mockOrgKey = {} as unknown as OrgKey; - const mockUserKey = {} as unknown as UserKey; + const mockOrgKey = {} as OrgKey; + const mockUserKey = {} as UserKey; keyService.orgKeys$.mockImplementation(() => of({ [mockOrgId]: mockOrgKey } as Record), @@ -99,7 +98,7 @@ describe("BitwardenPasswordProtectedImporter", () => { beforeEach(() => { accountService.activeAccount$ = of({ - id: newGuid() as UserId, + id: emptyGuid as UserId, email: "test@example.com", emailVerified: true, name: "Test User", diff --git a/libs/importer/src/importers/fsecure/fsecure-fsk-importer.ts b/libs/importer/src/importers/fsecure/fsecure-fsk-importer.ts index f572997cfe7..092a80c3cf0 100644 --- a/libs/importer/src/importers/fsecure/fsecure-fsk-importer.ts +++ b/libs/importer/src/importers/fsecure/fsecure-fsk-importer.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CipherType } from "@bitwarden/common/vault/enums"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -8,7 +6,7 @@ import { ImportResult } from "../../models/import-result"; import { BaseImporter } from "../base-importer"; import { Importer } from "../importer"; -import { FskEntry, FskEntryTypesEnum, FskFile } from "./fsecure-fsk-types"; +import { FskEntry, FskEntryType, FskFile } from "./fsecure-fsk-types"; export class FSecureFskImporter extends BaseImporter implements Importer { parse(data: string): Promise { @@ -19,37 +17,32 @@ export class FSecureFskImporter extends BaseImporter implements Importer { return Promise.resolve(result); } - for (const key in results.data) { - // eslint-disable-next-line - if (!results.data.hasOwnProperty(key)) { - continue; - } - - const value = results.data[key]; + for (const [, value] of Object.entries(results.data)) { const cipher = this.parseEntry(value); - result.ciphers.push(cipher); + if (cipher != undefined) { + result.ciphers.push(cipher); + } } result.success = true; return Promise.resolve(result); } - private parseEntry(entry: FskEntry): CipherView { + private parseEntry(entry: FskEntry): CipherView | undefined { const cipher = this.initLoginCipher(); cipher.name = this.getValueOrDefault(entry.service); cipher.notes = this.getValueOrDefault(entry.notes); cipher.favorite = entry.favorite > 0; switch (entry.type) { - case FskEntryTypesEnum.Login: + case FskEntryType.Login: this.handleLoginEntry(entry, cipher); break; - case FskEntryTypesEnum.CreditCard: + case FskEntryType.CreditCard: this.handleCreditCardEntry(entry, cipher); break; default: - return; - break; + return undefined; } this.convertToNoteIfNeeded(cipher); diff --git a/libs/importer/src/importers/fsecure/fsecure-fsk-types.ts b/libs/importer/src/importers/fsecure/fsecure-fsk-types.ts index 1235426d683..919ae4e8c82 100644 --- a/libs/importer/src/importers/fsecure/fsecure-fsk-types.ts +++ b/libs/importer/src/importers/fsecure/fsecure-fsk-types.ts @@ -6,12 +6,18 @@ export interface Data { [key: string]: FskEntry; } -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum FskEntryTypesEnum { - Login = 1, - CreditCard = 2, -} +/** + * Represents the different types of FSK entries. + */ +export const FskEntryType = Object.freeze({ + Login: 1, + CreditCard: 2, +}); + +/** + * Type representing valid FSK entry type values. + */ +export type FskEntryType = (typeof FskEntryType)[keyof typeof FskEntryType]; export interface FskEntry { color: string; @@ -26,7 +32,7 @@ export interface FskEntry { rev: string | number; service: string; style: string; - type: FskEntryTypesEnum; + type: FskEntryType; url: string; username: string; createdDate: number; // UNIX timestamp diff --git a/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts b/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts index 01c4572fcf9..ace0cda71a1 100644 --- a/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts +++ b/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts @@ -1,10 +1,16 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum IdpProvider { - Azure = 0, - OktaAuthServer = 1, - OktaNoAuthServer = 2, - Google = 3, - PingOne = 4, - OneLogin = 5, -} +/** + * Represents the different identity providers supported for authentication. + */ +export const IdpProvider = Object.freeze({ + Azure: 0, + OktaAuthServer: 1, + OktaNoAuthServer: 2, + Google: 3, + PingOne: 4, + OneLogin: 5, +} as const); + +/** + * Type representing valid identity provider values. + */ +export type IdpProvider = (typeof IdpProvider)[keyof typeof IdpProvider]; diff --git a/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts b/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts index a3be36c790e..8f45852c759 100644 --- a/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts +++ b/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts @@ -1,7 +1,13 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum LastpassLoginType { - MasterPassword = 0, +/** + * Represents LastPass login types. + */ +export const LastpassLoginType = Object.freeze({ + MasterPassword: 0, // Not sure what Types 1 and 2 are? - Federated = 3, -} + Federated: 3, +} as const); + +/** + * Type representing valid LastPass login type values. + */ +export type LastpassLoginType = (typeof LastpassLoginType)[keyof typeof LastpassLoginType]; diff --git a/libs/importer/src/importers/lastpass/access/enums/otp-method.ts b/libs/importer/src/importers/lastpass/access/enums/otp-method.ts index f1237160179..9d7e88798d4 100644 --- a/libs/importer/src/importers/lastpass/access/enums/otp-method.ts +++ b/libs/importer/src/importers/lastpass/access/enums/otp-method.ts @@ -1,7 +1,13 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum OtpMethod { - GoogleAuth, - MicrosoftAuth, - Yubikey, -} +/** + * Represents OTP authentication methods. + */ +export const OtpMethod = Object.freeze({ + GoogleAuth: 0, + MicrosoftAuth: 1, + Yubikey: 2, +} as const); + +/** + * Type representing valid OTP method values. + */ +export type OtpMethod = (typeof OtpMethod)[keyof typeof OtpMethod]; diff --git a/libs/importer/src/importers/lastpass/access/enums/platform.ts b/libs/importer/src/importers/lastpass/access/enums/platform.ts index 6870fc28c24..a58ba37958a 100644 --- a/libs/importer/src/importers/lastpass/access/enums/platform.ts +++ b/libs/importer/src/importers/lastpass/access/enums/platform.ts @@ -1,6 +1,12 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum Platform { - Desktop, - Mobile, -} +/** + * Platform types representing different device categories. + */ +export const Platform = Object.freeze({ + Desktop: 0, + Mobile: 1, +} as const); + +/** + * Type representing valid platform values. + */ +export type Platform = (typeof Platform)[keyof typeof Platform]; diff --git a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts index 053c83f2347..d19b5e7d0f3 100644 --- a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts +++ b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts @@ -14,12 +14,12 @@ import { BaseImporter } from "../base-importer"; import { Importer } from "../importer"; import { - CategoryEnum, + Category, Details, ExportData, FieldsEntity, Item, - LoginFieldTypeEnum, + LoginFieldType, Overview, PasswordHistoryEntity, SectionsEntity, @@ -45,38 +45,38 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { const cipher = this.initLoginCipher(); - const category = item.categoryUuid as CategoryEnum; + const category = item.categoryUuid as Category; switch (category) { - case CategoryEnum.Login: - case CategoryEnum.Database: - case CategoryEnum.Password: - case CategoryEnum.WirelessRouter: - case CategoryEnum.Server: - case CategoryEnum.API_Credential: + case Category.Login: + case Category.Database: + case Category.Password: + case Category.WirelessRouter: + case Category.Server: + case Category.API_Credential: cipher.type = CipherType.Login; cipher.login = new LoginView(); break; - case CategoryEnum.CreditCard: - case CategoryEnum.BankAccount: + case Category.CreditCard: + case Category.BankAccount: cipher.type = CipherType.Card; cipher.card = new CardView(); break; - case CategoryEnum.SecureNote: - case CategoryEnum.SoftwareLicense: - case CategoryEnum.EmailAccount: - case CategoryEnum.MedicalRecord: + case Category.SecureNote: + case Category.SoftwareLicense: + case Category.EmailAccount: + case Category.MedicalRecord: // case CategoryEnum.Document: cipher.type = CipherType.SecureNote; cipher.secureNote = new SecureNoteView(); cipher.secureNote.type = SecureNoteType.Generic; break; - case CategoryEnum.Identity: - case CategoryEnum.DriversLicense: - case CategoryEnum.OutdoorLicense: - case CategoryEnum.Membership: - case CategoryEnum.Passport: - case CategoryEnum.RewardsProgram: - case CategoryEnum.SocialSecurityNumber: + case Category.Identity: + case Category.DriversLicense: + case Category.OutdoorLicense: + case Category.Membership: + case Category.Passport: + case Category.RewardsProgram: + case Category.SocialSecurityNumber: cipher.type = CipherType.Identity; cipher.identity = new IdentityView(); break; @@ -166,10 +166,10 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { let fieldValue = loginField.value; let fieldType: FieldType = FieldType.Text; switch (loginField.fieldType) { - case LoginFieldTypeEnum.Password: + case LoginFieldType.Password: fieldType = FieldType.Hidden; break; - case LoginFieldTypeEnum.CheckBox: + case LoginFieldType.CheckBox: fieldValue = loginField.value !== "" ? "true" : "false"; fieldType = FieldType.Boolean; break; @@ -180,8 +180,8 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { }); } - private processDetails(category: CategoryEnum, details: Details, cipher: CipherView) { - if (category !== CategoryEnum.Password) { + private processDetails(category: Category, details: Details, cipher: CipherView) { + if (category !== Category.Password) { return; } @@ -191,7 +191,7 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { cipher.login.password = details.password; } - private processSections(category: CategoryEnum, sections: SectionsEntity[], cipher: CipherView) { + private processSections(category: Category, sections: SectionsEntity[], cipher: CipherView) { if (sections == null || sections.length === 0) { return; } @@ -206,7 +206,7 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { } private parseSectionFields( - category: CategoryEnum, + category: Category, fields: FieldsEntity[], cipher: CipherView, sectionTitle: string, @@ -232,20 +232,20 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { } switch (category) { - case CategoryEnum.Login: - case CategoryEnum.Database: - case CategoryEnum.EmailAccount: - case CategoryEnum.WirelessRouter: + case Category.Login: + case Category.Database: + case Category.EmailAccount: + case Category.WirelessRouter: break; - case CategoryEnum.Server: + case Category.Server: if (this.isNullOrWhitespace(cipher.login.uri) && field.id === "url") { cipher.login.uris = this.makeUriArray(fieldValue); return; } break; - case CategoryEnum.API_Credential: + case Category.API_Credential: if (this.fillApiCredentials(field, fieldValue, cipher)) { return; } @@ -258,7 +258,7 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { return; } - if (category === CategoryEnum.BankAccount) { + if (category === Category.BankAccount) { if (this.fillBankAccount(field, fieldValue, cipher)) { return; } @@ -281,34 +281,34 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { } switch (category) { - case CategoryEnum.Identity: + case Category.Identity: break; - case CategoryEnum.DriversLicense: + case Category.DriversLicense: if (this.fillDriversLicense(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.OutdoorLicense: + case Category.OutdoorLicense: if (this.fillOutdoorLicense(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.Membership: + case Category.Membership: if (this.fillMembership(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.Passport: + case Category.Passport: if (this.fillPassport(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.RewardsProgram: + case Category.RewardsProgram: if (this.fillRewardsProgram(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.SocialSecurityNumber: + case Category.SocialSecurityNumber: if (this.fillSSN(field, fieldValue, cipher)) { return; } diff --git a/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts b/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts index d7f4dec8f95..43f3bc4f7d6 100644 --- a/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts +++ b/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts @@ -25,30 +25,36 @@ export interface VaultAttributes { type: string; } -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum CategoryEnum { - Login = "001", - CreditCard = "002", - SecureNote = "003", - Identity = "004", - Password = "005", - Document = "006", - SoftwareLicense = "100", - BankAccount = "101", - Database = "102", - DriversLicense = "103", - OutdoorLicense = "104", - Membership = "105", - Passport = "106", - RewardsProgram = "107", - SocialSecurityNumber = "108", - WirelessRouter = "109", - Server = "110", - EmailAccount = "111", - API_Credential = "112", - MedicalRecord = "113", -} +/** + * Represents the different types of items that can be stored in 1Password. + */ +export const Category = Object.freeze({ + Login: "001", + CreditCard: "002", + SecureNote: "003", + Identity: "004", + Password: "005", + Document: "006", + SoftwareLicense: "100", + BankAccount: "101", + Database: "102", + DriversLicense: "103", + OutdoorLicense: "104", + Membership: "105", + Passport: "106", + RewardsProgram: "107", + SocialSecurityNumber: "108", + WirelessRouter: "109", + Server: "110", + EmailAccount: "111", + API_Credential: "112", + MedicalRecord: "113", +} as const); + +/** + * Represents valid 1Password category values. + */ +export type Category = (typeof Category)[keyof typeof Category]; export interface Item { uuid: string; @@ -69,23 +75,30 @@ export interface Details { password?: string | null; } -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum LoginFieldTypeEnum { - TextOrHtml = "T", - EmailAddress = "E", - URL = "U", - Number = "N", - Password = "P", - TextArea = "A", - PhoneNumber = "TEL", - CheckBox = "C", -} +/** + * Represents 1Password login field types that can be stored in login items. + */ +export const LoginFieldType = Object.freeze({ + TextOrHtml: "T", + EmailAddress: "E", + URL: "U", + Number: "N", + Password: "P", + TextArea: "A", + PhoneNumber: "TEL", + CheckBox: "C", +} as const); + +/** + * Type representing valid 1Password login field type values. + */ +export type LoginFieldType = (typeof LoginFieldType)[keyof typeof LoginFieldType]; + export interface LoginFieldsEntity { value: string; id: string; name: string; - fieldType: LoginFieldTypeEnum | string; + fieldType: LoginFieldType | string; designation?: string | null; } export interface SectionsEntity { diff --git a/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts b/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts index af2eb15a740..124c95a3d69 100644 --- a/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts +++ b/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts @@ -27,12 +27,19 @@ export type ProtonPassItem = { pinned: boolean; }; -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum ProtonPassItemState { - ACTIVE = 1, - TRASHED = 2, -} +/** + * Proton Pass item states as a const object. + * Represents the different states an item can be in (active or trashed). + */ +export const ProtonPassItemState = Object.freeze({ + ACTIVE: 1, + TRASHED: 2, +} as const); + +/** + * Type representing valid Proton Pass item state values. + */ +export type ProtonPassItemState = (typeof ProtonPassItemState)[keyof typeof ProtonPassItemState]; export type ProtonPassItemData = { metadata: ProtonPassItemMetadata; diff --git a/libs/importer/src/services/import.service.spec.ts b/libs/importer/src/services/import.service.spec.ts index c3d555af936..2a3963e19d7 100644 --- a/libs/importer/src/services/import.service.spec.ts +++ b/libs/importer/src/services/import.service.spec.ts @@ -341,19 +341,6 @@ describe("ImportService", () => { 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) => { @@ -373,27 +360,6 @@ describe("ImportService", () => { 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[] = []; diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 351d89be3fa..c17490ed4a4 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -10,8 +10,8 @@ import { CollectionView, } from "@bitwarden/admin-console/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { DeviceType } from "@bitwarden/common/enums"; -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"; @@ -22,7 +22,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic 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 { OrganizationId, UserId } 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"; import { CipherType, toCipherTypeName } from "@bitwarden/common/vault/enums"; @@ -138,18 +138,16 @@ export class ImportService implements ImportServiceAbstraction { } 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]) => { + const capabilities$ = combineLatest([type$]).pipe( + map(([type]) => { let loaders = availableLoaders(type, client); // Mac App Store is currently disabled due to sandboxing. let isUnsupported = this.system.environment.isMacAppStore(); - if (enabled && type === "bravecsv") { + // disable the chromium loader for Brave on Windows only + if (type === "bravecsv") { try { const device = this.system.environment.getDevice(); const isWindowsDesktop = device === DeviceType.WindowsDesktop; @@ -160,8 +158,8 @@ export class ImportService implements ImportServiceAbstraction { isUnsupported = true; } } - // If the feature flag is disabled, or if the browser is unsupported, remove the chromium loader - if (!enabled || isUnsupported) { + // If the browser is unsupported, remove the chromium loader + if (isUnsupported) { loaders = loaders?.filter((loader) => loader !== Loader.chromium); } @@ -241,10 +239,11 @@ export class ImportService implements ImportServiceAbstraction { try { await this.setImportTarget(importResult, organizationId, selectedImportTarget); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); if (organizationId != null) { - await this.handleOrganizationalImport(importResult, organizationId); + await this.handleOrganizationalImport(importResult, organizationId, userId); } else { - await this.handleIndividualImport(importResult); + await this.handleIndividualImport(importResult, userId); } } catch (error) { const errorResponse = new ErrorResponse(error, 400); @@ -422,16 +421,14 @@ export class ImportService implements ImportServiceAbstraction { } } - private async handleIndividualImport(importResult: ImportResult) { + private async handleIndividualImport(importResult: ImportResult, userId: UserId) { const request = new ImportCiphersRequest(); - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); for (let i = 0; i < importResult.ciphers.length; i++) { - const c = await this.cipherService.encrypt(importResult.ciphers[i], activeUserId); + const c = await this.cipherService.encrypt(importResult.ciphers[i], userId); request.ciphers.push(new CipherRequest(c)); } - const userKey = await this.keyService.getUserKey(activeUserId); + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + if (importResult.folders != null) { for (let i = 0; i < importResult.folders.length; i++) { const f = await this.folderService.encrypt(importResult.folders[i], userKey); @@ -449,20 +446,18 @@ export class ImportService implements ImportServiceAbstraction { private async handleOrganizationalImport( importResult: ImportResult, organizationId: OrganizationId, + userId: UserId, ) { const request = new ImportOrganizationCiphersRequest(); - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); for (let i = 0; i < importResult.ciphers.length; i++) { importResult.ciphers[i].organizationId = organizationId; - const c = await this.cipherService.encrypt(importResult.ciphers[i], activeUserId); + const c = await this.cipherService.encrypt(importResult.ciphers[i], userId); request.ciphers.push(new CipherRequest(c)); } if (importResult.collections != null) { for (let i = 0; i < importResult.collections.length; i++) { importResult.collections[i].organizationId = organizationId; - const c = await this.collectionService.encrypt(importResult.collections[i], activeUserId); + const c = await this.collectionService.encrypt(importResult.collections[i], userId); request.collections.push(new CollectionWithIdRequest(c)); } } diff --git a/libs/key-management-ui/src/lock/components/lock.component.html b/libs/key-management-ui/src/lock/components/lock.component.html index 9a8e8c9f768..77f603204b3 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.html +++ b/libs/key-management-ui/src/lock/components/lock.component.html @@ -120,73 +120,87 @@
- - - - {{ "masterPass" | i18n }} - - - - - - -
- - -

{{ "or" | i18n }}

- - + @if ( + (unlockWithMasterPasswordUnlockDataFlag$ | async) && + unlockOptions.masterPassword.enabled && + activeUnlockOption === UnlockOption.MasterPassword + ) { + + } @else { + + + + {{ "masterPass" | i18n }} + - + bitIconButton + bitSuffix + bitPasswordInputToggle + [(toggled)]="showPassword" + > - - - + + - -
- -
+
+ + +

{{ "or" | i18n }}

+ + + + + + + + + + +
+ + + } diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index 0f86481aeb9..5bac4a002a0 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -25,6 +25,7 @@ import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -98,9 +99,10 @@ describe("LockComponent", () => { }, }, }; + const mockConfigService = mock(); beforeEach(async () => { - jest.clearAllMocks(); + jest.resetAllMocks(); // Setup default mock returns mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web); @@ -156,6 +158,7 @@ describe("LockComponent", () => { { provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService }, { provide: BroadcasterService, useValue: mockBroadcasterService }, { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: ConfigService, useValue: mockConfigService }, ], }) .overrideProvider(DialogService, { useValue: mockDialogService }) @@ -366,6 +369,135 @@ describe("LockComponent", () => { }); }); + describe("successfulMasterPasswordUnlock", () => { + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + const masterPassword = "test-password"; + + beforeEach(async () => { + component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$); + }); + + it.each([ + [undefined as unknown as UserKey, undefined as unknown as string], + [null as unknown as UserKey, null as unknown as string], + [mockUserKey, undefined as unknown as string], + [mockUserKey, null as unknown as string], + [mockUserKey, ""], + [undefined as unknown as UserKey, masterPassword], + [null as unknown as UserKey, masterPassword], + ])( + "logs an error and doesn't unlock when called with invalid data", + async (userKey, masterPassword) => { + await component.successfulMasterPasswordUnlock({ userKey, masterPassword }); + + expect(mockLogService.error).toHaveBeenCalledWith( + "[LockComponent] successfulMasterPasswordUnlock called with invalid data.", + ); + expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); + }, + ); + + it.each([ + [false, undefined, false], + [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false], + [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true], + [true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false], + [false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true], + ])( + "unlocks and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy loaded from policy service", + async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => { + mockPolicyService.masterPasswordPolicyOptions$.mockReturnValue( + of(masterPasswordPolicyOptions), + ); + const passwordStrengthResult = { score: 1 } as ZXCVBNResult; + mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult); + mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + expect(mockPolicyService.masterPasswordPolicyOptions$).toHaveBeenCalledWith(userId); + if (masterPasswordPolicyOptions?.enforceOnLogin) { + expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith( + masterPassword, + component.activeAccount!.email, + ); + expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith( + passwordStrengthResult.score, + masterPassword, + masterPasswordPolicyOptions, + ); + } + if (forceSetPassword) { + expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.WeakMasterPassword, + userId, + ); + } else { + expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled(); + } + }, + ); + + it.each([ + [true, ClientType.Browser], + [false, ClientType.Cli], + [false, ClientType.Desktop], + [false, ClientType.Web], + ])( + "unlocks and navigate by url to previous url = %o when client type = %o and previous url was set", + async (shouldNavigate, clientType) => { + const previousUrl = "/test-url"; + component.clientType = clientType; + mockLockComponentService.getPreviousUrl.mockReturnValue(previousUrl); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + if (shouldNavigate) { + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith(previousUrl); + } else { + expect(mockRouter.navigateByUrl).not.toHaveBeenCalled(); + } + }, + ); + + it.each([ + ["/tabs/current", ClientType.Browser], + [undefined, ClientType.Cli], + ["vault", ClientType.Desktop], + ["vault", ClientType.Web], + ])( + "unlocks and navigate to success url = %o when client type = %o", + async (navigateUrl, clientType) => { + component.clientType = clientType; + mockLockComponentService.getPreviousUrl.mockReturnValue(null); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + expect(mockRouter.navigate).toHaveBeenCalledWith([navigateUrl]); + }, + ); + + it("unlocks and close browser extension popout on firefox extension", async () => { + component.shouldClosePopout = true; + mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + expect(mockLockComponentService.closeBrowserExtensionPopout).toHaveBeenCalled(); + }); + + function assertUnlocked(): void { + expect(mockKeyService.setUserKey).toHaveBeenCalledWith( + mockUserKey, + component.activeAccount!.id, + ); + } + }); + describe("unlockViaMasterPassword", () => { const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey; const masterPasswordVerificationResponse: MasterPasswordVerificationResponse = { diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index e67df395c1a..c35b497dc48 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -29,10 +29,12 @@ import { MasterPasswordVerificationResponse, } from "@bitwarden/common/auth/types/verification"; import { ClientType, DeviceType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -64,6 +66,8 @@ import { UnlockOptionValue, } from "../services/lock-component.service"; +import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component"; + const BroadcasterSubscriptionId = "LockComponent"; const clientTypeToSuccessRouteRecord: Partial> = { @@ -72,6 +76,12 @@ const clientTypeToSuccessRouteRecord: Partial> = { [ClientType.Browser]: "/tabs/current", }; +type AfterUnlockActions = { + passwordEvaluation?: { + masterPassword: string; + }; +}; + /// The minimum amount of time to wait after a process reload for a biometrics auto prompt to be possible /// Fixes safari autoprompt behavior const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000; @@ -87,12 +97,17 @@ const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000; FormFieldModule, AsyncActionsModule, IconButtonModule, + MasterPasswordLockComponent, ], }) export class LockComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); protected loading = true; + protected unlockWithMasterPasswordUnlockDataFlag$ = this.configService.getFeatureFlag$( + FeatureFlag.UnlockWithMasterPasswordUnlockData, + ); + activeAccount: Account | null = null; clientType?: ClientType; @@ -161,6 +176,7 @@ export class LockComponent implements OnInit, OnDestroy { private logoutService: LogoutService, private lockComponentService: LockComponentService, private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private configService: ConfigService, // desktop deps private broadcasterService: BroadcasterService, ) {} @@ -380,7 +396,7 @@ export class LockComponent implements OnInit, OnDestroy { // If user cancels biometric prompt, userKey is undefined. if (userKey) { - await this.setUserKeyAndContinue(userKey, false); + await this.setUserKeyAndContinue(userKey); } this.unlockingViaBiometrics = false; @@ -424,6 +440,7 @@ export class LockComponent implements OnInit, OnDestroy { } } + //TODO PM-25385 This code isn't used and should be removed when removing the UnlockWithMasterPasswordUnlockData feature flag. togglePassword() { this.showPassword = !this.showPassword; const input = document.getElementById( @@ -499,6 +516,7 @@ export class LockComponent implements OnInit, OnDestroy { } } + // TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag. private validateMasterPassword(): boolean { if (this.formGroup?.invalid) { this.toastService.showToast({ @@ -512,6 +530,7 @@ export class LockComponent implements OnInit, OnDestroy { return true; } + // TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag. async unlockViaMasterPassword() { if (!this.validateMasterPassword() || this.formGroup == null || this.activeAccount == null) { return; @@ -569,10 +588,33 @@ export class LockComponent implements OnInit, OnDestroy { return; } - await this.setUserKeyAndContinue(userKey, true); + await this.setUserKeyAndContinue(userKey, { + passwordEvaluation: { masterPassword }, + }); } - private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) { + async successfulMasterPasswordUnlock(event: { + userKey: UserKey; + masterPassword: string; + }): Promise { + if (event.userKey == null || !event.masterPassword) { + this.logService.error( + "[LockComponent] successfulMasterPasswordUnlock called with invalid data.", + ); + return; + } + + await this.setUserKeyAndContinue(event.userKey, { + passwordEvaluation: { + masterPassword: event.masterPassword, + }, + }); + } + + protected async setUserKeyAndContinue( + key: UserKey, + afterUnlockActions: AfterUnlockActions = {}, + ): Promise { if (this.activeAccount == null) { throw new Error("No active user."); } @@ -586,10 +628,10 @@ export class LockComponent implements OnInit, OnDestroy { // need to establish trust on the current device await this.deviceTrustService.trustDeviceIfRequired(this.activeAccount.id); - await this.doContinue(evaluatePasswordAfterUnlock); + await this.doContinue(afterUnlockActions); } - private async doContinue(evaluatePasswordAfterUnlock: boolean) { + private async doContinue(afterUnlockActions: AfterUnlockActions) { if (this.activeAccount == null) { throw new Error("No active user."); } @@ -597,7 +639,7 @@ export class LockComponent implements OnInit, OnDestroy { await this.biometricStateService.resetUserPromptCancelled(); this.messagingService.send("unlocked"); - if (evaluatePasswordAfterUnlock) { + if (afterUnlockActions.passwordEvaluation) { const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; if (userId == null) { throw new Error("No active user."); @@ -614,7 +656,7 @@ export class LockComponent implements OnInit, OnDestroy { ); } - if (this.requirePasswordChange()) { + if (this.requirePasswordChange(afterUnlockActions.passwordEvaluation.masterPassword)) { await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.WeakMasterPassword, userId, @@ -676,18 +718,15 @@ export class LockComponent implements OnInit, OnDestroy { * Checks if the master password meets the enforced policy requirements * If not, returns false */ - private requirePasswordChange(): boolean { + private requirePasswordChange(masterPassword: string): boolean { if ( this.enforcedMasterPasswordOptions == undefined || !this.enforcedMasterPasswordOptions.enforceOnLogin || - this.formGroup == null || this.activeAccount == null ) { return false; } - const masterPassword = this.formGroup.controls.masterPassword.value; - const passwordStrength = this.passwordStrengthService.getPasswordStrength( masterPassword, this.activeAccount.email, diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html new file mode 100644 index 00000000000..185fb0666c4 --- /dev/null +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html @@ -0,0 +1,55 @@ +
+ + {{ "masterPass" | i18n }} + + + + +
+ + +

{{ "or" | i18n }}

+ + @if (showBiometricsSwap()) { + + } + + @if (showPinSwap()) { + + } + + +
+
diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts new file mode 100644 index 00000000000..d40cc98df11 --- /dev/null +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts @@ -0,0 +1,472 @@ +import { DebugElement } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserKey } from "@bitwarden/common/types/key"; +import { + AsyncActionsModule, + ButtonModule, + FormFieldModule, + IconButtonModule, + ToastService, +} from "@bitwarden/components"; +import { BiometricsStatus } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; + +import { UnlockOption, UnlockOptions } from "../../services/lock-component.service"; + +import { MasterPasswordLockComponent } from "./master-password-lock.component"; + +describe("MasterPasswordLockComponent", () => { + let component: MasterPasswordLockComponent; + let fixture: ComponentFixture; + + const accountService = mock(); + const masterPasswordUnlockService = mock(); + const i18nService = mock(); + const toastService = mock(); + const logService = mock(); + + const mockMasterPassword = "testExample"; + const activeAccount: Account = { + id: "user-id" as UserId, + email: "user@example.com", + emailVerified: true, + name: "User", + }; + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + + const setupComponent = ( + unlockOptions: Partial = {}, + biometricUnlockBtnText: string = "default", + account: Account | null = activeAccount, + ) => { + const defaultOptions: UnlockOptions = { + masterPassword: { enabled: true }, + pin: { enabled: false }, + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.NotEnabledLocally, + }, + }; + + accountService.activeAccount$ = of(account); + fixture.componentRef.setInput("unlockOptions", { ...defaultOptions, ...unlockOptions }); + fixture.componentRef.setInput("biometricUnlockBtnText", biometricUnlockBtnText); + fixture.detectChanges(); + + return { + form: fixture.debugElement.query(By.css("form")), + component, + ...getFormElements(fixture.debugElement.query(By.css("form"))), + }; + }; + + const getFormElements = (form: DebugElement) => ({ + masterPasswordInput: form.query(By.css('input[formControlName="masterPassword"]')), + toggleButton: form.query(By.css("button[bitPasswordInputToggle]")), + submitButton: form.query(By.css('button[type="submit"]')), + logoutButton: form.query(By.css('button[type="button"]:not([bitPasswordInputToggle])')), + secondaryButton: form.query(By.css('button[buttonType="secondary"]')), + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + i18nService.t.mockImplementation((key: string) => key); + + await TestBed.configureTestingModule({ + imports: [ + MasterPasswordLockComponent, + JslibModule, + ReactiveFormsModule, + ButtonModule, + FormFieldModule, + AsyncActionsModule, + IconButtonModule, + ], + providers: [ + FormBuilder, + { provide: AccountService, useValue: accountService }, + { provide: MasterPasswordUnlockService, useValue: masterPasswordUnlockService }, + { provide: I18nService, useValue: i18nService }, + { provide: ToastService, useValue: toastService }, + { provide: LogService, useValue: logService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(MasterPasswordLockComponent); + component = fixture.componentInstance; + }); + + describe("form rendering", () => { + let elements: ReturnType; + + beforeEach(() => { + elements = setupComponent(); + }); + + it("creates form with proper structure", () => { + expect(component.formGroup).toBeDefined(); + expect(component.formGroup.controls.masterPassword).toBeDefined(); + }); + + const formElementTests = [ + { + name: "master password input", + selector: "masterPasswordInput", + expectations: (el: HTMLInputElement) => { + expect(el).toMatchObject({ + type: "password", + name: "masterPassword", + required: true, + }); + expect(el.attributes).toHaveProperty("bitInput"); + }, + }, + { + name: "password toggle button", + selector: "toggleButton", + expectations: (el: HTMLButtonElement) => { + expect(el.type).toBe("button"); + expect(el.attributes).toHaveProperty("bitIconButton"); + }, + }, + { + name: "unlock submit button", + selector: "submitButton", + expectations: (el: HTMLButtonElement) => { + expect(el).toMatchObject({ + type: "submit", + textContent: expect.stringContaining("unlock"), + }); + expect(el.attributes).toHaveProperty("bitButton"); + }, + }, + { + name: "logout button", + selector: "logoutButton", + expectations: (el: HTMLButtonElement) => { + expect(el).toMatchObject({ + type: "button", + textContent: expect.stringContaining("logOut"), + }); + expect(el.attributes).toHaveProperty("bitButton"); + }, + }, + ]; + + test.each(formElementTests)("renders $name correctly", ({ selector, expectations }) => { + const element = elements[selector as keyof typeof elements] as DebugElement; + expect(element).toBeTruthy(); + expectations(element.nativeElement); + }); + + const hiddenButtonTests = [ + { + case: "biometrics swap button when biometrics is undefined", + setup: () => + setupComponent( + { + pin: { enabled: false }, + biometrics: { + enabled: undefined as unknown as boolean, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }, + "swapBiometrics", + ), + expectHidden: true, + }, + { + case: "biometrics swap button when biometrics is disabled", + setup: () => setupComponent({}, "swapBiometrics"), + expectHidden: true, + }, + { + case: "PIN swap button when PIN is disabled", + setup: () => setupComponent({}), + expectHidden: true, + }, + { + case: "PIN swap button when PIN is undefined", + setup: () => + setupComponent({ + pin: { enabled: undefined as unknown as boolean }, + biometrics: { + enabled: undefined as unknown as boolean, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }), + expectHidden: true, + }, + ]; + + test.each(hiddenButtonTests)("doesn't render $case", ({ setup, expectHidden }) => { + const { secondaryButton } = setup(); + expect(!!secondaryButton).toBe(!expectHidden); + }); + }); + + describe("password input", () => { + let setup: ReturnType; + beforeEach(() => { + setup = setupComponent(); + }); + + it("should bind form input to masterPassword form control", async () => { + const input = setup.masterPasswordInput; + expect(input).toBeTruthy(); + expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); + expect(component.formGroup).toBeTruthy(); + const masterPasswordControl = component.formGroup!.get("masterPassword"); + expect(masterPasswordControl).toBeTruthy(); + + masterPasswordControl!.setValue("test-password"); + fixture.detectChanges(); + + const inputElement = input.nativeElement as HTMLInputElement; + expect(inputElement.value).toEqual("test-password"); + }); + + it("should validate required master password field", async () => { + const formGroup = component.formGroup; + + // Initially form should be invalid (empty required field) + expect(formGroup?.invalid).toEqual(true); + expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(true); + + // Set a value + formGroup?.get("masterPassword")?.setValue("test-password"); + + expect(formGroup?.invalid).toEqual(false); + expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(false); + }); + + it("should toggle password visibility when toggle button is clicked", async () => { + const toggleButton = setup.toggleButton; + expect(toggleButton).toBeTruthy(); + expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement); + const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement; + const input = setup.masterPasswordInput; + expect(input).toBeTruthy(); + expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); + const inputElement = input.nativeElement as HTMLInputElement; + + // Initially password should be hidden + expect(inputElement.type).toEqual("password"); + + // Click toggle button + toggleButtonElement.click(); + fixture.detectChanges(); + + expect(inputElement.type).toEqual("text"); + + // Click toggle button again + toggleButtonElement.click(); + fixture.detectChanges(); + + expect(inputElement.type).toEqual("password"); + }); + }); + + describe("logout", () => { + it("emits logOut event when logout button is clicked", () => { + const setup = setupComponent(); + let logoutEmitted = false; + component.logOut.subscribe(() => { + logoutEmitted = true; + }); + + expect(setup.logoutButton).toBeTruthy(); + expect(setup.logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement); + const logoutButtonElement = setup.logoutButton.nativeElement as HTMLButtonElement; + + // Click logout button + logoutButtonElement.click(); + + expect(logoutEmitted).toBe(true); + }); + }); + + describe("swap buttons", () => { + const swapButtonScenarios = [ + { + name: "PIN swap button when PIN is enabled", + unlockOptions: { + pin: { enabled: true }, + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }, + expectedText: "unlockWithPin", + expectedUnlockOption: UnlockOption.Pin, + shouldShow: true, + shouldEnable: true, + }, + { + name: "PIN swap button when PIN is disabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }, + expectedText: "unlockWithPin", + expectedUnlockOption: UnlockOption.Pin, + shouldShow: false, + shouldEnable: false, + }, + { + name: "biometrics swap button when biometrics status is available and enabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: true, + shouldEnable: true, + }, + { + name: "biometrics swap button when biometrics status is available and disabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.Available }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: true, + shouldEnable: false, + }, + { + name: "biometrics swap button when biometrics biometrics status is unsupported and enabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.PlatformUnsupported }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: false, + shouldEnable: false, + }, + { + name: "biometrics swap button when biometrics status is unsupported and disabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: false, + shouldEnable: false, + }, + ]; + + test.each(swapButtonScenarios)( + "renders and handles $name", + ({ unlockOptions, expectedText, expectedUnlockOption, shouldShow, shouldEnable }) => { + const { secondaryButton, component } = setupComponent(unlockOptions, expectedText); + + if (shouldShow) { + expect(secondaryButton).toBeTruthy(); + expect(secondaryButton.nativeElement.textContent?.trim()).toBe(expectedText); + + if (shouldEnable) { + secondaryButton.nativeElement.click(); + expect(component.activeUnlockOption()).toBe(expectedUnlockOption); + } else { + expect(secondaryButton.nativeElement.getAttribute("aria-disabled")).toBe("true"); + } + } else { + expect(secondaryButton).toBeFalsy(); + } + }, + ); + }); + + describe("submit", () => { + test.each([null, undefined as unknown as string, ""])( + "won't unlock and show password invalid toast when master password is %s", + async (value) => { + component.formGroup.controls.masterPassword.setValue(value); + + await component.submit(); + + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: i18nService.t("errorOccurred"), + message: i18nService.t("masterPasswordRequired"), + }); + expect(masterPasswordUnlockService.unlockWithMasterPassword).not.toHaveBeenCalled(); + }, + ); + + test.each([null as unknown as Account, undefined as unknown as Account])( + "throws error when active account is %s", + async (value) => { + accountService.activeAccount$ = of(value); + component.formGroup.controls.masterPassword.setValue(mockMasterPassword); + + await expect(component.submit()).rejects.toThrow("Null or undefined account"); + + expect(masterPasswordUnlockService.unlockWithMasterPassword).not.toHaveBeenCalled(); + }, + ); + + it("shows an error toast and logs the error when unlock with master password fails", async () => { + const customError = new Error("Specialized error message"); + masterPasswordUnlockService.unlockWithMasterPassword.mockRejectedValue(customError); + accountService.activeAccount$ = of(activeAccount); + component.formGroup.controls.masterPassword.setValue(mockMasterPassword); + + await component.submit(); + + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: i18nService.t("errorOccurred"), + message: i18nService.t("invalidMasterPassword"), + }); + expect(logService.error).toHaveBeenCalledWith( + "[MasterPasswordLockComponent] Failed to unlock via master password", + customError, + ); + }); + + it("emits userKey when unlock is successful", async () => { + masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey); + accountService.activeAccount$ = of(activeAccount); + component.formGroup.controls.masterPassword.setValue(mockMasterPassword); + let emittedEvent: { userKey: UserKey; masterPassword: string } | undefined; + component.successfulUnlock.subscribe( + (event: { userKey: UserKey; masterPassword: string }) => { + emittedEvent = event; + }, + ); + + await component.submit(); + + expect(emittedEvent?.userKey).toEqual(mockUserKey); + expect(emittedEvent?.masterPassword).toEqual(mockMasterPassword); + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + }); + }); +}); diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts new file mode 100644 index 00000000000..c9399cc3ab2 --- /dev/null +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts @@ -0,0 +1,111 @@ +import { Component, computed, inject, input, model, output } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserKey } from "@bitwarden/common/types/key"; +import { + AsyncActionsModule, + ButtonModule, + FormFieldModule, + IconButtonModule, + ToastService, +} from "@bitwarden/components"; +import { BiometricsStatus } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +import { + UnlockOption, + UnlockOptions, + UnlockOptionValue, +} from "../../services/lock-component.service"; + +@Component({ + selector: "bit-master-password-lock", + templateUrl: "master-password-lock.component.html", + imports: [ + JslibModule, + ReactiveFormsModule, + ButtonModule, + FormFieldModule, + AsyncActionsModule, + IconButtonModule, + ], +}) +export class MasterPasswordLockComponent { + private readonly accountService = inject(AccountService); + private readonly masterPasswordUnlockService = inject(MasterPasswordUnlockService); + private readonly i18nService = inject(I18nService); + private readonly toastService = inject(ToastService); + private readonly logService = inject(LogService); + UnlockOption = UnlockOption; + + activeUnlockOption = model.required(); + + unlockOptions = input.required(); + biometricUnlockBtnText = input.required(); + showPinSwap = computed(() => this.unlockOptions().pin.enabled ?? false); + biometricsAvailable = computed(() => this.unlockOptions().biometrics.enabled ?? false); + showBiometricsSwap = computed(() => { + const status = this.unlockOptions().biometrics.biometricsStatus; + return ( + status !== BiometricsStatus.PlatformUnsupported && + status !== BiometricsStatus.NotEnabledLocally + ); + }); + + successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>(); + logOut = output(); + + formGroup = new FormGroup({ + masterPassword: new FormControl("", { + validators: [Validators.required], + updateOn: "submit", + }), + }); + + submit = async () => { + this.formGroup.markAllAsTouched(); + const masterPassword = this.formGroup.controls.masterPassword.value; + if (this.formGroup.invalid || !masterPassword) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("masterPasswordRequired"), + }); + return; + } + + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + + await this.unlockViaMasterPassword(masterPassword, activeUserId); + }; + + private async unlockViaMasterPassword( + masterPassword: string, + activeUserId: UserId, + ): Promise { + try { + const userKey = await this.masterPasswordUnlockService.unlockWithMasterPassword( + masterPassword, + activeUserId, + ); + this.successfulUnlock.emit({ userKey, masterPassword }); + } catch (error) { + this.logService.error( + "[MasterPasswordLockComponent] Failed to unlock via master password", + error, + ); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidMasterPassword"), + }); + } + } +} diff --git a/libs/key-management/src/abstractions/key.service.ts b/libs/key-management/src/abstractions/key.service.ts index c6c751bf25c..e4bb83cb2fd 100644 --- a/libs/key-management/src/abstractions/key.service.ts +++ b/libs/key-management/src/abstractions/key.service.ts @@ -7,6 +7,7 @@ import { EncryptedString, EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { WrappedSigningKey } from "@bitwarden/common/key-management/types"; import { KeySuffixOptions, HashPurpose } from "@bitwarden/common/platform/enums"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; @@ -236,8 +237,10 @@ export abstract class KeyService { */ abstract getOrgKey(orgId: string): Promise; /** - * Uses the org key to derive a new symmetric key for encrypting data - * @param key The organization's symmetric key + * Makes a fresh attachment content encryption key and returns it along with a wrapped (encrypted) version of it. + * @deprecated Do not use this for new code / new cryptographic designs. + * @param key The organization's symmetric key or the user's user key to wrap the attachment key with + * @returns The new attachment content encryption key and the wrapped version of it */ abstract makeDataEncKey( key: T, @@ -272,6 +275,14 @@ export abstract class KeyService { * @param encPrivateKey An encrypted private key */ abstract setPrivateKey(encPrivateKey: string, userId: UserId): Promise; + /** + * Sets the user's encrypted signing key in storage + * In contrast to the private key, the decrypted signing key + * is not stored in memory outside of the SDK. + * @param encryptedSigningKey An encrypted signing key + * @param userId The user id of the user to set the signing key for + */ + abstract setUserSigningKey(encryptedSigningKey: WrappedSigningKey, userId: UserId): Promise; /** * Gets an observable stream of the given users decrypted private key, will emit null if the user @@ -416,7 +427,13 @@ export abstract class KeyService { * * @throws If an invalid user id is passed in. */ - abstract userPublicKey$(userId: UserId): Observable; + abstract userPublicKey$(userId: UserId): Observable; + + /** + * Gets a users signing keys from local state. + * The observable will emit null, exactly if the local state returns null. + */ + abstract userSigningKey$(userId: UserId): Observable; /** * Validates that a userkey is correct for a given user diff --git a/libs/key-management/src/key.service.spec.ts b/libs/key-management/src/key.service.spec.ts index 52fecf26c71..46d1125711b 100644 --- a/libs/key-management/src/key.service.spec.ts +++ b/libs/key-management/src/key.service.spec.ts @@ -11,6 +11,7 @@ import { } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { UnsignedPublicKey, WrappedSigningKey } from "@bitwarden/common/key-management/types"; import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout"; import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -25,6 +26,7 @@ import { USER_ENCRYPTED_PRIVATE_KEY, USER_EVER_HAD_USER_KEY, USER_KEY, + USER_KEY_ENCRYPTED_SIGNING_KEY, } from "@bitwarden/common/platform/services/key-state/user-key.state"; import { UserKeyDefinition } from "@bitwarden/common/platform/state"; import { @@ -432,6 +434,7 @@ describe("keyService", () => { USER_ENCRYPTED_ORGANIZATION_KEYS, USER_ENCRYPTED_PROVIDER_KEYS, USER_ENCRYPTED_PRIVATE_KEY, + USER_KEY_ENCRYPTED_SIGNING_KEY, USER_KEY, ])("key removal", (key: UserKeyDefinition) => { it(`clears ${key.key} for the specified user when specified`, async () => { @@ -540,6 +543,51 @@ describe("keyService", () => { }); }); + describe("userSigningKey$", () => { + it("returns the signing key when the user has a signing key set", async () => { + const fakeSigningKey = "" as WrappedSigningKey; + const fakeSigningKeyState = stateProvider.singleUser.getFake( + mockUserId, + USER_KEY_ENCRYPTED_SIGNING_KEY, + ); + fakeSigningKeyState.nextState(fakeSigningKey); + + const signingKey = await firstValueFrom(keyService.userSigningKey$(mockUserId)); + + expect(signingKey).toEqual(fakeSigningKey); + }); + + it("returns null when the user does not have a signing key set", async () => { + const signingKey = await firstValueFrom(keyService.userSigningKey$(mockUserId)); + + expect(signingKey).toBeFalsy(); + }); + }); + + describe("setUserSigningKey", () => { + it("throws if the signing key is null", async () => { + await expect(keyService.setUserSigningKey(null as any, mockUserId)).rejects.toThrow( + "No user signing key provided.", + ); + }); + it("throws if the userId is null", async () => { + await expect( + keyService.setUserSigningKey("" as WrappedSigningKey, null as unknown as UserId), + ).rejects.toThrow("No userId provided."); + }); + it("sets the signing key for the user", async () => { + const fakeSigningKey = "" as WrappedSigningKey; + const fakeSigningKeyState = stateProvider.singleUser.getFake( + mockUserId, + USER_KEY_ENCRYPTED_SIGNING_KEY, + ); + fakeSigningKeyState.nextState(null); + await keyService.setUserSigningKey(fakeSigningKey, mockUserId); + expect(fakeSigningKeyState.nextMock).toHaveBeenCalledTimes(1); + expect(fakeSigningKeyState.nextMock).toHaveBeenCalledWith(fakeSigningKey); + }); + }); + describe("cipherDecryptionKeys$", () => { function fakePrivateKeyDecryption(encryptedPrivateKey: EncString, key: SymmetricCryptoKey) { const output = new Uint8Array(64); @@ -1132,12 +1180,12 @@ describe("keyService", () => { keyService.userPrivateKey$ = jest.fn().mockReturnValue(new BehaviorSubject("private key")); cryptoFunctionService.rsaExtractPublicKey.mockResolvedValue( - Utils.fromUtf8ToArray("public key"), + Utils.fromUtf8ToArray("public key") as UnsignedPublicKey, ); const key = await firstValueFrom(keyService.userEncryptionKeyPair$(mockUserId)); expect(key).toEqual({ privateKey: "private key", - publicKey: Utils.fromUtf8ToArray("public key"), + publicKey: Utils.fromUtf8ToArray("public key") as UnsignedPublicKey, }); }); }); diff --git a/libs/key-management/src/key.service.ts b/libs/key-management/src/key.service.ts index cdf7d594f28..a13c74e96d2 100644 --- a/libs/key-management/src/key.service.ts +++ b/libs/key-management/src/key.service.ts @@ -28,6 +28,7 @@ import { } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; +import { WrappedSigningKey } from "@bitwarden/common/key-management/types"; import { VaultTimeoutStringType } from "@bitwarden/common/key-management/vault-timeout"; import { VAULT_TIMEOUT } from "@bitwarden/common/key-management/vault-timeout/services/vault-timeout-settings.state"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; @@ -44,6 +45,7 @@ import { USER_ENCRYPTED_PRIVATE_KEY, USER_EVER_HAD_USER_KEY, USER_KEY, + USER_KEY_ENCRYPTED_SIGNING_KEY, } from "@bitwarden/common/platform/services/key-state/user-key.state"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CsprngArray } from "@bitwarden/common/types/csprng"; @@ -398,8 +400,10 @@ export class DefaultKeyService implements KeyServiceAbstraction { throw new Error("No key provided"); } - const newSymKey = await this.keyGenerationService.createKey(512); - return this.buildProtectedSymmetricKey(key, newSymKey); + // Content encryption key is AES256_CBC_HMAC + const cek = await this.keyGenerationService.createKey(512); + const wrappedCek = await this.encryptService.wrapSymmetricKey(cek, key); + return [cek, wrappedCek]; } private async clearOrgKeys(userId: UserId): Promise { @@ -505,6 +509,10 @@ export class DefaultKeyService implements KeyServiceAbstraction { await this.stateProvider.setUserState(USER_ENCRYPTED_PRIVATE_KEY, null, userId); } + private async clearSigningKey(userId: UserId): Promise { + await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_SIGNING_KEY, null, userId); + } + async clearPinKeys(userId: UserId): Promise { if (userId == null) { throw new Error("UserId is required"); @@ -537,6 +545,7 @@ export class DefaultKeyService implements KeyServiceAbstraction { await this.clearOrgKeys(userId); await this.clearProviderKeys(userId); await this.clearKeyPair(userId); + await this.clearSigningKey(userId); await this.clearPinKeys(userId); await this.stateProvider.setUserState(USER_EVER_HAD_USER_KEY, null, userId); } @@ -758,6 +767,10 @@ export class DefaultKeyService implements KeyServiceAbstraction { return phrase; } + /** + * @deprecated + * This should only be used for wrapping the user key with a master key or stretched master key. + */ private async buildProtectedSymmetricKey( encryptionKey: SymmetricCryptoKey, newSymKey: SymmetricCryptoKey, @@ -792,7 +805,7 @@ export class DefaultKeyService implements KeyServiceAbstraction { return null; } - return (await this.cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey; + return await this.cryptoFunctionService.rsaExtractPublicKey(privateKey); } userPrivateKey$(userId: UserId): Observable { @@ -808,7 +821,7 @@ export class DefaultKeyService implements KeyServiceAbstraction { return null; } - const publicKey = (await this.derivePublicKey(privateKey))!; + const publicKey = (await this.derivePublicKey(privateKey))! as UserPublicKey; return { privateKey, publicKey }; }), ); @@ -905,6 +918,27 @@ export class DefaultKeyService implements KeyServiceAbstraction { ); } + async setUserSigningKey(userSigningKey: WrappedSigningKey, userId: UserId): Promise { + if (userSigningKey == null) { + throw new Error("No user signing key provided."); + } + if (userId == null) { + throw new Error("No userId provided."); + } + await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_SIGNING_KEY, userSigningKey, userId); + } + + userSigningKey$(userId: UserId): Observable { + return this.stateProvider.getUser(userId, USER_KEY_ENCRYPTED_SIGNING_KEY).state$.pipe( + map((encryptedSigningKey) => { + if (encryptedSigningKey == null) { + return null; + } + return encryptedSigningKey as WrappedSigningKey; + }), + ); + } + orgKeys$(userId: UserId): Observable | null> { return this.cipherDecryptionKeys$(userId).pipe(map((keys) => keys?.orgKeys ?? null)); } diff --git a/libs/node/src/services/node-crypto-function.service.ts b/libs/node/src/services/node-crypto-function.service.ts index f3ac71b13e7..22cc5756f30 100644 --- a/libs/node/src/services/node-crypto-function.service.ts +++ b/libs/node/src/services/node-crypto-function.service.ts @@ -3,6 +3,7 @@ import * as crypto from "crypto"; import * as forge from "node-forge"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { UnsignedPublicKey } from "@bitwarden/common/key-management/types"; import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { @@ -232,7 +233,7 @@ export class NodeCryptoFunctionService implements CryptoFunctionService { return Promise.resolve(this.toUint8Buffer(decipher)); } - rsaExtractPublicKey(privateKey: Uint8Array): Promise { + async rsaExtractPublicKey(privateKey: Uint8Array): Promise { const privateKeyByteString = Utils.fromBufferToByteString(privateKey); const privateKeyAsn1 = forge.asn1.fromDer(privateKeyByteString); const forgePrivateKey: any = forge.pki.privateKeyFromAsn1(privateKeyAsn1); @@ -240,11 +241,11 @@ export class NodeCryptoFunctionService implements CryptoFunctionService { const publicKeyAsn1 = forge.pki.publicKeyToAsn1(forgePublicKey); const publicKeyByteString = forge.asn1.toDer(publicKeyAsn1).data; const publicKeyArray = Utils.fromByteStringToArray(publicKeyByteString); - return Promise.resolve(publicKeyArray); + return publicKeyArray as UnsignedPublicKey; } - async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]> { - return new Promise<[Uint8Array, Uint8Array]>((resolve, reject) => { + async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[UnsignedPublicKey, Uint8Array]> { + return new Promise<[UnsignedPublicKey, Uint8Array]>((resolve, reject) => { forge.pki.rsa.generateKeyPair( { bits: length, @@ -266,7 +267,7 @@ export class NodeCryptoFunctionService implements CryptoFunctionService { const privateKeyByteString = forge.asn1.toDer(privateKeyPkcs8).getBytes(); const privateKey = Utils.fromByteStringToArray(privateKeyByteString); - resolve([publicKey, privateKey]); + resolve([publicKey as UnsignedPublicKey, privateKey]); }, ); }); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts index a2df4ec27dc..df317835392 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts @@ -12,7 +12,7 @@ import { import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { CipherId, UserId } from "@bitwarden/common/types/guid"; +import { CipherId, emptyGuid, UserId } 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"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -179,7 +179,7 @@ describe("VaultExportService", () => { let restrictedItemTypesService: Partial; let fetchMock: jest.Mock; - const userId = "" as UserId; + const userId = emptyGuid as UserId; beforeEach(() => { cryptoFunctionService = mock(); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index e51c9543bba..e7a97801e09 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -201,6 +201,10 @@ export class IndividualVaultExportService } private async getEncryptedExport(activeUserId: UserId): Promise { + if (!activeUserId) { + throw new Error("User ID must not be null or undefined"); + } + let folders: Folder[] = []; let ciphers: Cipher[] = []; const promises = []; @@ -225,7 +229,7 @@ export class IndividualVaultExportService await Promise.all(promises); - const userKey = await this.keyService.getUserKey(activeUserId); + const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId)); const encKeyValidation = await this.encryptService.encryptString(Utils.newGuid(), userKey); const jsonDoc: BitwardenEncryptedIndividualJsonExport = { diff --git a/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts b/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts index a85048c23fa..acd7d9129bd 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/components/export-scope-callout.component.ts @@ -9,7 +9,9 @@ import { OrganizationService } from "@bitwarden/common/admin-console/abstraction import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { getById } from "@bitwarden/common/platform/misc/rxjs-operators"; +import { OrganizationId } from "@bitwarden/common/types/guid"; import { CalloutModule } from "@bitwarden/components"; +import { ExportFormat } from "@bitwarden/vault-export-core"; @Component({ selector: "tools-export-scope-callout", @@ -25,9 +27,9 @@ export class ExportScopeCalloutComponent { }; /* Optional OrganizationId, if not provided, it will display individual vault export message */ - readonly organizationId = input(); + readonly organizationId = input(); /* Optional export format, determines which individual export description to display */ - readonly exportFormat = input(); + readonly exportFormat = input(); /* The description key to use for organizational exports */ readonly orgExportDescription = input(); @@ -47,13 +49,13 @@ export class ExportScopeCalloutComponent { } private async getScopeMessage( - organizationId: string, - exportFormat: string, + organizationId: OrganizationId | undefined, + exportFormat: ExportFormat | undefined, orgExportDescription: string, ): Promise { const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - if (organizationId != null) { + if (organizationId) { // exporting from organizational vault const org = await firstValueFrom( this.organizationService.organizations$(userId).pipe(getById(organizationId)), @@ -64,18 +66,19 @@ export class ExportScopeCalloutComponent { description: orgExportDescription, scopeIdentifier: org?.name ?? "", }; - } else { - this.scopeConfig = { - // exporting from individual vault - title: "exportingPersonalVaultTitle", - description: - exportFormat === "zip" - ? "exportingIndividualVaultWithAttachmentsDescription" - : "exportingIndividualVaultDescription", - scopeIdentifier: - (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email)))) ?? - "", - }; + + return; } + + // exporting from individual vault + this.scopeConfig = { + title: "exportingPersonalVaultTitle", + description: + exportFormat === "zip" + ? "exportingIndividualVaultWithAttachmentsDescription" + : "exportingIndividualVaultDescription", + scopeIdentifier: + (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.email)))) ?? "", + }; } } diff --git a/libs/tools/export/vault-export/vault-export-ui/src/enums/encrypted-export-type.enum.ts b/libs/tools/export/vault-export/vault-export-ui/src/enums/encrypted-export-type.enum.ts index 2f416e4a49a..c321a487515 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/enums/encrypted-export-type.enum.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/enums/encrypted-export-type.enum.ts @@ -1,6 +1,10 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum EncryptedExportType { - AccountEncrypted = 0, - FileEncrypted = 1, -} +/** A type of encrypted export. */ +export const EncryptedExportType = Object.freeze({ + /** Export is encrypted using the Bitwarden account key. */ + AccountEncrypted: 0, + /** Export is encrypted using a separate file password/key. */ + FileEncrypted: 1, +} as const); + +/** A type of encrypted export. */ +export type EncryptedExportType = (typeof EncryptedExportType)[keyof typeof EncryptedExportType]; diff --git a/libs/tools/generator/extensions/history/src/legacy-password-history-decryptor.ts b/libs/tools/generator/extensions/history/src/legacy-password-history-decryptor.ts index 91998589bdd..c0c32824cea 100644 --- a/libs/tools/generator/extensions/history/src/legacy-password-history-decryptor.ts +++ b/libs/tools/generator/extensions/history/src/legacy-password-history-decryptor.ts @@ -1,3 +1,5 @@ +import { firstValueFrom } from "rxjs"; + import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { UserId } from "@bitwarden/common/types/guid"; @@ -15,7 +17,11 @@ export class LegacyPasswordHistoryDecryptor { /** Decrypts a password history. */ async decrypt(history: GeneratedPasswordHistory[]): Promise { - const key = await this.keyService.getUserKey(this.userId); + const key = await firstValueFrom(this.keyService.userKey$(this.userId)); + + if (key == undefined) { + throw new Error("No user key found for decryption"); + } const promises = (history ?? []).map(async (item) => { const encrypted = new EncString(item.password); diff --git a/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts b/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts index ab8d7bae9fe..1eb9e864fb7 100644 --- a/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts +++ b/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts @@ -1,7 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { inject, Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { Send } from "@bitwarden/common/tools/send/models/domain/send"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; @@ -12,11 +15,13 @@ import { SendFormService } from "../abstractions/send-form.service"; @Injectable() export class DefaultSendFormService implements SendFormService { + private accountService = inject(AccountService); private sendApiService: SendApiService = inject(SendApiService); private sendService = inject(SendService); async decryptSend(send: Send): Promise { - return await send.decrypt(); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + return await send.decrypt(userId); } async saveSend(send: SendView, file: File | ArrayBuffer, config: SendFormConfig) { diff --git a/libs/vault/src/cipher-form/cipher-form.stories.ts b/libs/vault/src/cipher-form/cipher-form.stories.ts index 3059deb5814..3b1017ffe32 100644 --- a/libs/vault/src/cipher-form/cipher-form.stories.ts +++ b/libs/vault/src/cipher-form/cipher-form.stories.ts @@ -10,7 +10,7 @@ import { moduleMetadata, StoryObj, } from "@storybook/angular"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, 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 @@ -155,6 +155,20 @@ export default { } as NudgeStatus), }, }, + { + provide: CipherArchiveService, + useValue: { + userCanArchive$: of(false), + }, + }, + { + provide: AccountService, + useValue: { + activeAccount$: of({ + name: "User 1", + }), + } as Partial, + }, { provide: CipherFormService, useClass: TestAddEditFormService, diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.spec.ts b/libs/vault/src/cipher-form/components/cipher-form.component.spec.ts index ac384ee3fd8..08afee33ae3 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.spec.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.spec.ts @@ -2,14 +2,18 @@ import { ChangeDetectorRef } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ReactiveFormsModule } from "@angular/forms"; import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { Cipher } from "@bitwarden/common/vault/models/domain/cipher"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view"; import { ToastService } from "@bitwarden/components"; +import { UserId } from "@bitwarden/user-core"; import { CipherFormConfig } from "../abstractions/cipher-form-config.service"; import { CipherFormService } from "../abstractions/cipher-form.service"; @@ -23,6 +27,10 @@ describe("CipherFormComponent", () => { const decryptCipher = jest.fn().mockResolvedValue(new CipherView()); + const mockAccountService = mock(); + const mockCipherArchiveService = mock(); + const mockAddEditFormService = { saveCipher: jest.fn(), decryptCipher }; + beforeEach(async () => { decryptCipher.mockClear(); @@ -32,13 +40,15 @@ describe("CipherFormComponent", () => { { provide: ChangeDetectorRef, useValue: {} }, { provide: I18nService, useValue: { t: (key: string) => key } }, { provide: ToastService, useValue: { showToast: jest.fn() } }, - { provide: CipherFormService, useValue: { saveCipher: jest.fn(), decryptCipher } }, + { provide: CipherFormService, useValue: mockAddEditFormService }, { provide: CipherFormCacheService, useValue: { init: jest.fn(), getCachedCipherView: jest.fn() }, }, { provide: ViewCacheService, useValue: { signal: jest.fn(() => (): any => null) } }, { provide: ConfigService, useValue: mock() }, + { provide: AccountService, useValue: mockAccountService }, + { provide: CipherArchiveService, useValue: mockCipherArchiveService }, ], }).compileComponents(); }); @@ -53,6 +63,29 @@ describe("CipherFormComponent", () => { expect(component).toBeTruthy(); }); + describe("submit", () => { + beforeEach(() => { + component.config = { mode: "edit" } as CipherFormConfig; + + component["updatedCipherView"] = new CipherView(); + component["updatedCipherView"].archivedDate = new Date(); + }); + + it("should remove archivedDate when user cannot archive and cipher is archived", async () => { + mockAccountService.activeAccount$ = of({ id: "user-id" as UserId } as Account); + mockCipherArchiveService.userCanArchive$.mockReturnValue(of(false)); + mockAddEditFormService.saveCipher = jest.fn().mockResolvedValue(new CipherView()); + + const originalArchivedDate = component["updatedCipherView"]?.archivedDate; + expect(originalArchivedDate).not.toBeNull(); + + await component.submit(); + + expect(component["updatedCipherView"]?.archivedDate).toBeNull(); + expect(mockCipherArchiveService.userCanArchive$).toHaveBeenCalledWith("user-id"); + }); + }); + describe("website", () => { it("should return null if updatedCipherView is null", () => { component["updatedCipherView"] = null as any; diff --git a/libs/vault/src/cipher-form/components/cipher-form.component.ts b/libs/vault/src/cipher-form/components/cipher-form.component.ts index 19e7f9d2d90..117dd98ba43 100644 --- a/libs/vault/src/cipher-form/components/cipher-form.component.ts +++ b/libs/vault/src/cipher-form/components/cipher-form.component.ts @@ -17,9 +17,12 @@ import { } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, FormGroup, ReactiveFormsModule } from "@angular/forms"; -import { BehaviorSubject, Subject } from "rxjs"; +import { BehaviorSubject, firstValueFrom, Subject, switchMap } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service"; import { CipherType, SecureNoteType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { @@ -301,6 +304,8 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci private i18nService: I18nService, private changeDetectorRef: ChangeDetectorRef, private cipherFormCacheService: CipherFormCacheService, + private cipherArchiveService: CipherArchiveService, + private accountService: AccountService, ) {} /** @@ -342,6 +347,18 @@ export class CipherFormComponent implements AfterViewInit, OnInit, OnChanges, Ci } } + const userCanArchive = await firstValueFrom( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)), + ), + ); + + // If the item is archived but user has lost archive permissions, unarchive the item. + if (!userCanArchive && this.updatedCipherView.archivedDate) { + this.updatedCipherView.archivedDate = null; + } + const savedCipher = await this.addEditFormService.saveCipher( this.updatedCipherView, this.config, diff --git a/package-lock.json b/package-lock.json index 346f9b9dc9d..8da94affe5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,7 +82,7 @@ "@babel/preset-env": "7.24.8", "@compodoc/compodoc": "1.1.26", "@electron/notarize": "3.0.1", - "@electron/rebuild": "3.7.2", + "@electron/rebuild": "4.0.1", "@eslint/compat": "1.2.9", "@lit-labs/signals": "0.1.2", "@ngtools/webpack": "19.2.14", @@ -131,7 +131,7 @@ "chromatic": "13.1.2", "concurrently": "9.2.0", "copy-webpack-plugin": "13.0.0", - "cross-env": "10.0.0", + "cross-env": "10.1.0", "css-loader": "7.1.2", "electron": "36.9.3", "electron-builder": "26.0.12", @@ -5851,21 +5851,21 @@ } }, "node_modules/@electron/rebuild": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.7.2.tgz", - "integrity": "sha512-19/KbIR/DAxbsCkiaGMXIdPnMCJLkcf8AvGnduJtWBs/CBwiAjY1apCqOLVxrXg+rtXFCngbXhBanWjxLUt1Mg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.1.tgz", + "integrity": "sha512-iMGXb6Ib7H/Q3v+BKZJoETgF9g6KMNZVbsO4b7Dmpgb5qTFqyFTzqW9F3TOSHdybv2vKYKzSS9OiZL+dcJb+1Q==", "dev": true, "license": "MIT", "dependencies": { - "@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "@malept/cross-spawn-promise": "^2.0.0", "chalk": "^4.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", - "fs-extra": "^10.0.0", "got": "^11.7.0", - "node-abi": "^3.45.0", - "node-api-version": "^0.2.0", + "graceful-fs": "^4.2.11", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^11.2.0", "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", @@ -5876,22 +5876,20 @@ "electron-rebuild": "lib/cli.js" }, "engines": { - "node": ">=12.13.0" + "node": ">=22.12.0" } }, - "node_modules/@electron/rebuild/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "node_modules/@electron/rebuild/node_modules/node-abi": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.14.0.tgz", + "integrity": "sha512-E4n91K4Nk1Rch2KzD+edU2bfZTP4W42GypAUDXU4vu1A+4u9PvUNDkGI0dXbsy8ZeF3WGj0SD/uHxnXD/sW+3w==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "semver": "^7.6.3" }, "engines": { - "node": ">=12" + "node": ">=22.12.0" } }, "node_modules/@electron/universal": { @@ -19538,9 +19536,9 @@ "peer": true }, "node_modules/cross-env": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.0.0.tgz", - "integrity": "sha512-aU8qlEK/nHYtVuN4p7UQgAwVljzMg8hB4YK5ThRqD2l/ziSnryncPNn7bMLt5cFYsKVKBh8HqLqyCoTupEUu7Q==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 38ddfc19e21..5f2bb4fdfe6 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@babel/preset-env": "7.24.8", "@compodoc/compodoc": "1.1.26", "@electron/notarize": "3.0.1", - "@electron/rebuild": "3.7.2", + "@electron/rebuild": "4.0.1", "@eslint/compat": "1.2.9", "@lit-labs/signals": "0.1.2", "@ngtools/webpack": "19.2.14", @@ -94,7 +94,7 @@ "chromatic": "13.1.2", "concurrently": "9.2.0", "copy-webpack-plugin": "13.0.0", - "cross-env": "10.0.0", + "cross-env": "10.1.0", "css-loader": "7.1.2", "electron": "36.9.3", "electron-builder": "26.0.12",