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 49d9d4c079f..8ba5f981453 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' @@ -914,7 +1147,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' @@ -1172,7 +1405,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 7781b352672..3bcca910194 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" 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..21609432a4b 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, @@ -999,7 +1005,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 +1030,7 @@ export default class MainBackground { this.tokenService, this.authService, this.stateProvider, + this.securityStateService, ); this.syncServiceListener = new SyncServiceListener( 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 2e684802c51..d1d81beb0e6 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 @@ -337,7 +337,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 4230d4038cd..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 }} 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 81a4c9191a5..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(); 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/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 15f52d0e65c..c66c74a3ea9 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,6 +172,8 @@ 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 { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, @@ -177,6 +181,8 @@ import { 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 +708,11 @@ const safeProviders: SafeProvider[] = [ KdfConfigService, ], }), + safeProvider({ + provide: SecurityStateService, + useClass: DefaultSecurityStateService, + deps: [StateProvider], + }), safeProvider({ provide: RestrictedItemTypesService, useClass: RestrictedItemTypesService, @@ -797,6 +808,11 @@ const safeProviders: SafeProvider[] = [ useClass: SendApiService, deps: [ApiServiceAbstraction, FileUploadServiceAbstraction, InternalSendService], }), + safeProvider({ + provide: KeyApiService, + useClass: DefaultKeyApiService, + deps: [ApiServiceAbstraction], + }), safeProvider({ provide: SyncService, useClass: DefaultSyncService, @@ -825,6 +841,7 @@ const safeProviders: SafeProvider[] = [ TokenServiceAbstraction, AuthServiceAbstraction, StateProvider, + SecurityStateService, ], }), safeProvider({ @@ -1399,6 +1416,11 @@ const safeProviders: SafeProvider[] = [ useClass: BillingApiService, deps: [ApiServiceAbstraction], }), + safeProvider({ + provide: OrganizationMetadataServiceAbstraction, + useClass: DefaultOrganizationMetadataService, + deps: [BillingApiServiceAbstraction, ConfigService], + }), safeProvider({ provide: BillingAccountProfileStateService, useClass: DefaultBillingAccountProfileStateService, @@ -1523,6 +1545,7 @@ const safeProviders: SafeProvider[] = [ AccountServiceAbstraction, KdfConfigService, KeyService, + SecurityStateService, ApiServiceAbstraction, StateProvider, ConfigService, 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 d7fb9e6fed8..631875258ec 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -25,6 +25,8 @@ 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 */ @@ -35,7 +37,6 @@ export enum FeatureFlag { /* Tools */ DesktopSendUIRefresh = "desktop-send-ui-refresh", UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators", - UseChromiumImporter = "pm-23982-chromium-importer", /* DIRT */ EventBasedOrganizationIntegrations = "event-based-organization-integrations", @@ -82,7 +83,6 @@ export const DefaultFeatureFlagValue = { /* Tools */ [FeatureFlag.DesktopSendUIRefresh]: FALSE, [FeatureFlag.UseSdkPasswordGenerators]: FALSE, - [FeatureFlag.UseChromiumImporter]: FALSE, /* DIRT */ [FeatureFlag.EventBasedOrganizationIntegrations]: FALSE, @@ -104,6 +104,8 @@ 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 */ 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/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/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.ts b/libs/common/src/vault/services/cipher.service.ts index f0e2f9f9404..8de7f48c2ba 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], @@ -2059,21 +2060,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/callout/callout.component.html b/libs/components/src/callout/callout.component.html index a73c8c6dc44..0336cf95cda 100644 --- a/libs/components/src/callout/callout.component.html +++ b/libs/components/src/callout/callout.component.html @@ -13,13 +13,17 @@ aria-hidden="true" > } -
+
@if (title) { -
+
{{ title }}
} -
+
diff --git a/libs/components/src/callout/callout.component.ts b/libs/components/src/callout/callout.component.ts index 62321a34d91..3d6c9f480dc 100644 --- a/libs/components/src/callout/callout.component.ts +++ b/libs/components/src/callout/callout.component.ts @@ -37,6 +37,7 @@ export class CalloutComponent { readonly type = input("info"); readonly icon = input(); readonly title = input(); + readonly truncate = input(false); readonly useAlertRole = input(false); readonly iconComputed = computed(() => this.icon() ?? defaultIcon[this.type()]); readonly titleComputed = computed(() => { diff --git a/libs/components/src/callout/callout.stories.ts b/libs/components/src/callout/callout.stories.ts index c2185203034..2b10c6e7d13 100644 --- a/libs/components/src/callout/callout.stories.ts +++ b/libs/components/src/callout/callout.stories.ts @@ -122,3 +122,54 @@ export const WithTextButton: Story = { icon: "", }, }; + +export const Truncate: Story = { + render: (args) => ({ + props: args, + template: ` +
+ (args)}> + This is a really long callout that should truncate when it reaches the end of the container. This is a really long title that should truncate. Like really, really, really, ridiculously long content. + +
+ `, + }), + args: { + title: + "This is a really long title that should truncate. Like really, really, really, ridiculously long title", + truncate: true, + }, +}; + +export const TruncateOnlyContent: Story = { + render: (args) => ({ + props: args, + template: ` +
+ (args)}> + This is a really long callout that should truncate when it reaches the end of the container. This is a really long title that should truncate. Like really, really, really, ridiculously long content. + +
+ `, + }), + args: { + truncate: true, + }, +}; + +export const TruncateOnlyTitle: Story = { + render: (args) => ({ + props: args, + template: ` +
+ (args)}> + +
+ `, + }), + args: { + title: + "This is a really long title that should truncate. Like really, really, really, ridiculously long title", + truncate: true, + }, +}; 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..4050ae9fb4b 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -11,7 +11,6 @@ import { } from "@bitwarden/admin-console/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/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"; @@ -138,18 +137,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 +157,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); } 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-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/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",