1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-02 01:33:22 +00:00

Merge remote-tracking branch 'origin/autofill/pm-26089/beeep-use-tracing-in-macos-provider' into feature/passkey-provider

This commit is contained in:
Jeffrey Holland
2025-10-17 14:07:28 +02:00
208 changed files with 7966 additions and 1192 deletions

View File

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

View File

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

View File

@@ -174,7 +174,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -323,7 +323,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -429,7 +429,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -665,6 +665,239 @@ jobs:
path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml
if-no-files-found: error
windows-beta:
name: Windows Beta Build
runs-on: windows-2022
needs: setup
permissions:
contents: read
id-token: write
defaults:
run:
shell: pwsh
working-directory: apps/desktop
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
NODE_OPTIONS: --max_old_space_size=4096
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: ${{ env._NODE_VERSION }}
- name: Install AST
run: dotnet tool install --global AzureSignTool --version 4.0.1
- name: Print environment
run: |
node --version
npm --version
choco --version
rustup show
- name: Log in to Azure
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve secrets
id: retrieve-secrets
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "code-signing-vault-url,
code-signing-client-id,
code-signing-tenant-id,
code-signing-client-secret,
code-signing-cert-name"
- name: Log out from Azure
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/azure-logout@main
- name: Install Node dependencies
run: npm ci
working-directory: ./
- name: Download SDK Artifacts
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
workflow: build-wasm-internal.yml
workflow_conclusion: success
branch: ${{ inputs.sdk_branch }}
artifacts: sdk-internal
repo: bitwarden/sdk-internal
path: ../sdk-internal
if_no_artifact_found: fail
- name: Override SDK
if: ${{ inputs.sdk_branch != '' && needs.setup.outputs.has_secrets == 'true' }}
working-directory: ./
run: |
ls -l ../
npm link ../sdk-internal
- name: Cache Native Module
uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
id: cache
with:
path: |
apps/desktop/desktop_native/napi/*.node
apps/desktop/desktop_native/dist/*
key: rust-${{ runner.os }}-${{ hashFiles('apps/desktop/desktop_native/**/*') }}
- name: Build Native Module
if: steps.cache.outputs.cache-hit != 'true'
working-directory: apps/desktop/desktop_native
run: node build.js cross-platform
- name: Build
run: npm run build
- name: Pack
if: ${{ needs.setup.outputs.has_secrets == 'false' }}
run: npm run pack:win:beta
- name: Pack & Sign
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
env:
ELECTRON_BUILDER_SIGN: 1
SIGNING_VAULT_URL: ${{ steps.retrieve-secrets.outputs.code-signing-vault-url }}
SIGNING_CLIENT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-client-id }}
SIGNING_TENANT_ID: ${{ steps.retrieve-secrets.outputs.code-signing-tenant-id }}
SIGNING_CLIENT_SECRET: ${{ steps.retrieve-secrets.outputs.code-signing-client-secret }}
SIGNING_CERT_NAME: ${{ steps.retrieve-secrets.outputs.code-signing-cert-name }}
run: npm run pack:win:beta
- name: Rename appx files for store
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
run: |
Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx" `
-Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx"
Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx" `
-Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx"
Copy-Item "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx" `
-Destination "./dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx"
- name: Fix NSIS artifact names for auto-updater
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
run: |
Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z `
-NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z
Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z `
-NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z
Rename-Item -Path .\dist\nsis-web\Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z `
-NewName bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z
- name: Upload portable exe artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe
path: apps/desktop/dist/Bitwarden-Beta-Portable-${{ env._PACKAGE_VERSION }}.exe
if-no-files-found: error
- name: Upload installer exe artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe
path: apps/desktop/dist/nsis-web/Bitwarden-Beta-Installer-${{ env._PACKAGE_VERSION }}.exe
if-no-files-found: error
- name: Upload appx ia32 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32.appx
if-no-files-found: error
- name: Upload store appx ia32 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-ia32-store.appx
if-no-files-found: error
- name: Upload NSIS ia32 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z
path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-ia32.nsis.7z
if-no-files-found: error
- name: Upload appx x64 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64.appx
if-no-files-found: error
- name: Upload store appx x64 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-x64-store.appx
if-no-files-found: error
- name: Upload NSIS x64 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z
path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-x64.nsis.7z
if-no-files-found: error
- name: Upload appx ARM64 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64.appx
if-no-files-found: error
- name: Upload store appx ARM64 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx
path: apps/desktop/dist/Bitwarden-Beta-${{ env._PACKAGE_VERSION }}-arm64-store.appx
if-no-files-found: error
- name: Upload NSIS ARM64 artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z
path: apps/desktop/dist/nsis-web/bitwarden-beta-${{ env._PACKAGE_VERSION }}-arm64.nsis.7z
if-no-files-found: error
- name: Upload auto-update artifact
if: ${{ needs.setup.outputs.has_secrets == 'true' }}
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: ${{ needs.setup.outputs.release_channel }}-beta.yml
path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml
if-no-files-found: error
macos-build:
name: MacOS Build
@@ -688,7 +921,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -920,7 +1153,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -1184,7 +1417,7 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

494
apps/browser/project.json Normal file
View File

@@ -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"]
}
}
}
}

View File

@@ -558,7 +558,7 @@
"message": "Archive",
"description": "Verb"
},
"unarchive": {
"unArchive": {
"message": "Unarchive"
},
"itemsInArchive": {
@@ -570,11 +570,11 @@
"noItemsInArchiveDesc": {
"message": "Archived items will appear here and will be excluded from general search results and autofill suggestions."
},
"itemSentToArchive": {
"message": "Item sent to archive"
"itemWasSentToArchive": {
"message": "Item was sent to archive"
},
"itemRemovedFromArchive": {
"message": "Item removed from archive"
"itemUnarchived": {
"message": "Item was unarchived"
},
"archiveItem": {
"message": "Archive item"
@@ -5579,17 +5579,37 @@
"hasItemsVaultNudgeTitle": {
"message": "Welcome to your vault!"
},
"phishingPageTitle":{
"message": "Phishing website"
"phishingPageTitleV2":{
"message": "Phishing attempt detected"
},
"phishingPageCloseTab": {
"message": "Close tab"
"phishingPageSummary": {
"message": "The site you are attempting to visit is a known malicious site and a security risk."
},
"phishingPageContinue": {
"message": "Continue"
"phishingPageCloseTabV2": {
"message": "Close this tab"
},
"phishingPageLearnWhy": {
"message": "Why are you seeing this?"
"phishingPageContinueV2": {
"message": "Continue to this site (not recommended)"
},
"phishingPageExplanation1": {
"message": "This site was found in ",
"description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this."
},
"phishingPageExplanation2": {
"message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.",
"description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this."
},
"phishingPageLearnMore" : {
"message": "Learn more about phishing detection"
},
"protectedBy": {
"message": "Protected by $PRODUCT$",
"placeholders": {
"product": {
"content": "$1",
"example": "Bitwarden Phishing Blocker"
}
}
},
"hasItemsVaultNudgeBodyOne": {
"message": "Autofill items for the current page"

View File

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

View File

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

View File

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

View File

@@ -100,6 +100,8 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-
import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service";
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation";
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
import { DefaultSecurityStateService } from "@bitwarden/common/key-management/security-state/services/security-state.service";
import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service";
import {
DefaultVaultTimeoutSettingsService,
@@ -452,6 +454,7 @@ export default class MainBackground {
taskService: TaskService;
cipherEncryptionService: CipherEncryptionService;
private restrictedItemTypesService: RestrictedItemTypesService;
private securityStateService: SecurityStateService;
ipcContentScriptManagerService: IpcContentScriptManagerService;
ipcService: IpcService;
@@ -668,6 +671,8 @@ export default class MainBackground {
logoutCallback,
);
this.securityStateService = new DefaultSecurityStateService(this.stateProvider);
this.popupViewCacheBackgroundService = new PopupViewCacheBackgroundService(
messageListener,
this.globalStateProvider,
@@ -830,6 +835,7 @@ export default class MainBackground {
this.accountService,
this.kdfConfigService,
this.keyService,
this.securityStateService,
this.apiService,
this.stateProvider,
this.configService,
@@ -984,6 +990,7 @@ export default class MainBackground {
this.sendStateProvider = new SendStateProvider(this.stateProvider);
this.sendService = new SendService(
this.accountService,
this.keyService,
this.i18nService,
this.keyGenerationService,
@@ -999,7 +1006,6 @@ export default class MainBackground {
this.avatarService = new AvatarService(this.apiService, this.stateProvider);
this.providerService = new ProviderService(this.stateProvider);
this.syncService = new DefaultSyncService(
this.masterPasswordService,
this.accountService,
@@ -1025,6 +1031,7 @@ export default class MainBackground {
this.tokenService,
this.authService,
this.stateProvider,
this.securityStateService,
);
this.syncServiceListener = new SyncServiceListener(

View File

@@ -1,4 +0,0 @@
<span>{{ "phishingPageLearnWhy"| i18n}}</span>
<a href="http://bitwarden.com/help/phishing-blocked/" bitLink block buttonType="primary">
{{ "learnMore" | i18n }}
</a>

View File

@@ -1,13 +1,46 @@
<div class="tw-flex tw-flex-col tw-gap-2">
<bit-form-field>
<bit-label>{{ "phishingPageTitle" | i18n }}</bit-label>
<input bitInput disabled type="text" [value]="phishingHost" />
</bit-form-field>
<div class="tw-flex tw-flex-col">
<div class="tw-flex tw-gap-4 tw-items-baseline">
<bit-icon-tile size="large" icon="bwi-exclamation-triangle" variant="danger"></bit-icon-tile>
<h1 bitTypography="h2" noMargin class="!tw-mb-0">{{ "phishingPageTitleV2" | i18n }}</h1>
</div>
<button type="button" (click)="closeTab()" bitButton buttonType="primary">
{{ "phishingPageCloseTab" | i18n }}
</button>
<button type="button" (click)="continueAnyway()" bitButton buttonType="danger">
{{ "phishingPageContinue" | i18n }}
</button>
<hr class="!tw-mt-6 !tw-mb-4 !tw-border-secondary-100" />
<p bitTypography="body1">{{ "phishingPageSummary" | i18n }}</p>
<bit-callout class="tw-mb-0" type="danger" icon="bwi-globe" [title]="null">
<span class="tw-font-mono">{{ phishingHost$ | async }}</span>
</bit-callout>
<bit-callout class="tw-mt-2" [icon]="null" type="default">
<p bitTypography="body2">
{{ "phishingPageExplanation1" | i18n }}<b>Phishing.Database</b
>{{ "phishingPageExplanation2" | i18n }}
</p>
<a
bitLink
linkType="primary"
rel="noreferrer"
target="_blank"
href="https://bitwarden.com/help/phishing-blocked/"
>
{{ "phishingPageLearnMore" | i18n }}<i class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-callout>
<div class="tw-flex tw-flex-col tw-gap-4 tw-items-center tw-mt-2">
<button type="button" (click)="closeTab()" bitButton buttonType="primary" [block]="true">
{{ "phishingPageCloseTabV2" | i18n }}
</button>
<button
class="tw-text-sm"
type="button"
(click)="continueAnyway()"
bitLink
linkType="secondary"
>
{{ "phishingPageContinueV2" | i18n }}
</button>
</div>
</div>

View File

@@ -1,10 +1,10 @@
// eslint-disable-next-line no-restricted-imports
import { CommonModule } from "@angular/common";
// eslint-disable-next-line no-restricted-imports
import { Component, OnDestroy } from "@angular/core";
import { Component, inject } from "@angular/core";
// eslint-disable-next-line no-restricted-imports
import { ActivatedRoute, RouterModule } from "@angular/router";
import { Subject, takeUntil } from "rxjs";
import { map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import {
@@ -13,12 +13,16 @@ import {
CheckboxModule,
FormFieldModule,
IconModule,
IconTileComponent,
LinkModule,
CalloutComponent,
TypographyModule,
} from "@bitwarden/components";
import { PhishingDetectionService } from "../services/phishing-detection.service";
@Component({
selector: "dirt-phishing-warning",
standalone: true,
templateUrl: "phishing-warning.component.html",
imports: [
@@ -31,18 +35,16 @@ import { PhishingDetectionService } from "../services/phishing-detection.service
CheckboxModule,
ButtonModule,
RouterModule,
IconTileComponent,
CalloutComponent,
TypographyModule,
],
})
export class PhishingWarning implements OnDestroy {
phishingHost = "";
private destroy$ = new Subject<void>();
constructor(private activatedRoute: ActivatedRoute) {
this.activatedRoute.queryParamMap.pipe(takeUntil(this.destroy$)).subscribe((params) => {
this.phishingHost = params.get("phishingHost") || "";
});
}
export class PhishingWarning {
private activatedRoute = inject(ActivatedRoute);
protected phishingHost$ = this.activatedRoute.queryParamMap.pipe(
map((params) => params.get("phishingHost") || ""),
);
async closeTab() {
await PhishingDetectionService.requestClosePhishingWarningPage();
@@ -50,9 +52,4 @@ export class PhishingWarning implements OnDestroy {
async continueAnyway() {
await PhishingDetectionService.requestContinueToDangerousUrl();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,137 @@
// TODO: This needs to be dealt with by moving this folder or updating the lint rule.
/* eslint-disable no-restricted-imports */
import { ActivatedRoute, RouterModule } from "@angular/router";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { BehaviorSubject, of } from "rxjs";
import { DeactivatedOrg } from "@bitwarden/assets/svg";
import { ClientType } from "@bitwarden/common/enums";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { AnonLayoutComponent, I18nMockService } from "@bitwarden/components";
import { PhishingWarning } from "./phishing-warning.component";
import { ProtectedByComponent } from "./protected-by-component";
class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
getApplicationVersion = () => Promise.resolve("Version 2024.1.1");
getClientType = () => ClientType.Web;
}
/**
* Helper function to create ActivatedRoute mock with query parameters
*/
function mockActivatedRoute(queryParams: Record<string, string>) {
return {
provide: ActivatedRoute,
useValue: {
queryParamMap: of({
get: (key: string) => queryParams[key] || null,
}),
queryParams: of(queryParams),
},
};
}
type StoryArgs = {
phishingHost: string;
};
export default {
title: "Browser/DIRT/Phishing Warning",
component: PhishingWarning,
decorators: [
moduleMetadata({
imports: [AnonLayoutComponent, ProtectedByComponent, RouterModule],
providers: [
{
provide: PlatformUtilsService,
useClass: MockPlatformUtilsService,
},
{
provide: I18nService,
useFactory: () =>
new I18nMockService({
accessing: "Accessing",
appLogoLabel: "Bitwarden logo",
phishingPageTitleV2: "Phishing attempt detected",
phishingPageCloseTabV2: "Close this tab",
phishingPageSummary:
"The site you are attempting to visit is a known malicious site and a security risk.",
phishingPageContinueV2: "Continue to this site (not recommended)",
phishingPageExplanation1: "This site was found in ",
phishingPageExplanation2:
", an open-source list of known phishing sites used for stealing personal and sensitive information.",
phishingPageLearnMore: "Learn more about phishing detection",
protectedBy: (product) => `Protected by ${product}`,
learnMore: "Learn more",
danger: "error",
}),
},
{
provide: EnvironmentService,
useValue: {
environment$: new BehaviorSubject({
getHostname() {
return "bitwarden.com";
},
}).asObservable(),
},
},
mockActivatedRoute({ phishingHost: "malicious-example.com" }),
],
}),
],
render: (args) => ({
props: args,
template: /*html*/ `
<auth-anon-layout
[hideIcon]="true"
[hideBackgroundIllustration]="true"
>
<dirt-phishing-warning></dirt-phishing-warning>
<dirt-phishing-protected-by slot="secondary"></dirt-phishing-protected-by>
</auth-anon-layout>
`,
}),
argTypes: {
phishingHost: {
control: "text",
description: "The suspicious host that was blocked",
},
},
args: {
phishingHost: "malicious-example.com",
pageIcon: DeactivatedOrg,
},
} satisfies Meta<StoryArgs & { pageIcon: any }>;
type Story = StoryObj<StoryArgs & { pageIcon: any }>;
export const Default: Story = {
args: {
phishingHost: "malicious-example.com",
},
decorators: [
moduleMetadata({
providers: [mockActivatedRoute({ phishingHost: "malicious-example.com" })],
}),
],
};
export const LongHostname: Story = {
args: {
phishingHost: "very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com",
},
decorators: [
moduleMetadata({
providers: [
mockActivatedRoute({
phishingHost:
"very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com",
}),
],
}),
],
};

View File

@@ -0,0 +1 @@
<span class="tw-text-muted">{{ "protectedBy" | i18n: "Bitwarden Phishing Blocker" }}</span>

View File

@@ -4,13 +4,12 @@ import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ButtonModule } from "@bitwarden/components";
import { ButtonModule, LinkModule } from "@bitwarden/components";
@Component({
selector: "dirt-phishing-protected-by",
standalone: true,
templateUrl: "learn-more-component.html",
imports: [CommonModule, CommonModule, JslibModule, ButtonModule],
templateUrl: "protected-by-component.html",
imports: [CommonModule, CommonModule, JslibModule, ButtonModule, LinkModule],
})
export class LearnMoreComponent {
constructor() {}
}
export class ProtectedByComponent {}

View File

@@ -116,15 +116,15 @@ export class PhishingDetectionService {
/**
* Sends a message to the phishing detection service to close the warning page
*/
static requestClosePhishingWarningPage(): void {
void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close });
static async requestClosePhishingWarningPage() {
await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close });
}
/**
* Sends a message to the phishing detection service to continue to the caught url
*/
static async requestContinueToDangerousUrl() {
void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue });
await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue });
}
/**

View File

@@ -24,7 +24,6 @@ import {
VaultIcon,
LockIcon,
TwoFactorAuthSecurityKeyIcon,
DeactivatedOrg,
} from "@bitwarden/assets/svg";
import {
LoginComponent,
@@ -54,8 +53,8 @@ import { BlockedDomainsComponent } from "../autofill/popup/settings/blocked-doma
import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component";
import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component";
import { PremiumV2Component } from "../billing/popup/settings/premium-v2.component";
import { LearnMoreComponent } from "../dirt/phishing-detection/pages/learn-more-component";
import { PhishingWarning } from "../dirt/phishing-detection/pages/phishing-warning.component";
import { ProtectedByComponent } from "../dirt/phishing-detection/pages/protected-by-component";
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import BrowserPopupUtils from "../platform/browser/browser-popup-utils";
import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service";
@@ -718,14 +717,13 @@ const routes: Routes = [
},
{
path: "",
component: LearnMoreComponent,
component: ProtectedByComponent,
outlet: "secondary",
},
],
data: {
pageIcon: DeactivatedOrg,
pageTitle: "Bitwarden blocked it!",
pageSubtitle: "Bitwarden blocked a known phishing site from loading.",
hideIcon: true,
hideBackgroundIllustration: true,
showReadonlyHostname: true,
} satisfies AnonLayoutWrapperData,
},

View File

@@ -382,7 +382,7 @@ app-root {
}
}
main:not(popup-page main) {
main:not(popup-page main):not(auth-anon-layout main) {
position: absolute;
top: 44px;
bottom: 0;

View File

@@ -302,7 +302,7 @@ export class ItemMoreOptionsComponent {
await this.cipherArchiveService.archiveWithServer(this.cipher.id as CipherId, activeUserId);
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemSentToArchive"),
message: this.i18nService.t("itemWasSentToArchive"),
});
}
}

View File

@@ -49,7 +49,7 @@
{{ "clone" | i18n }}
</button>
<button type="button" bitMenuItem (click)="unarchive(cipher)">
{{ "unarchive" | i18n }}
{{ "unArchive" | i18n }}
</button>
<button
type="button"

View File

@@ -133,7 +133,7 @@ export class ArchiveComponent {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t("itemRemovedFromArchive"),
message: this.i18nService.t("itemUnarchived"),
});
}

View File

@@ -10,14 +10,18 @@ const configurator = require("./config/config");
const manifest = require("./webpack/manifest");
const AngularCheckPlugin = require("./webpack/angular-check");
module.exports.getEnv = function getEnv() {
const ENV = (process.env.ENV = process.env.NODE_ENV);
module.exports.getEnv = function getEnv(params) {
const ENV = params.env || (process.env.ENV = process.env.NODE_ENV);
const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2;
const browser = process.env.BROWSER ?? "chrome";
return { ENV, manifestVersion, browser };
};
const DEFAULT_PARAMS = {
outputPath: path.resolve(__dirname, "build"),
};
/**
* @param {{
* configName: string;
@@ -29,15 +33,20 @@ module.exports.getEnv = function getEnv() {
* entry: string;
* };
* tsConfig: string;
* outputPath?: string;
* mode?: string;
* env?: string;
* additionalEntries?: { [outputPath: string]: string }
* }} params - The input parameters for building the config.
*/
module.exports.buildConfig = function buildConfig(params) {
params = { ...DEFAULT_PARAMS, ...params };
if (process.env.NODE_ENV == null) {
process.env.NODE_ENV = "development";
}
const { ENV, manifestVersion, browser } = module.exports.getEnv();
const { ENV, manifestVersion, browser } = module.exports.getEnv(params);
console.log(`Building Manifest Version ${manifestVersion} app - ${params.configName} version`);
@@ -103,7 +112,7 @@ module.exports.buildConfig = function buildConfig(params) {
{
loader: "babel-loader",
options: {
configFile: "../../babel.config.json",
configFile: path.resolve(__dirname, "../../babel.config.json"),
cacheDirectory: ENV === "development",
compact: ENV !== "development",
},
@@ -130,43 +139,52 @@ module.exports.buildConfig = function buildConfig(params) {
const plugins = [
new HtmlWebpackPlugin({
template: "./src/popup/index.ejs",
template: path.resolve(__dirname, "src/popup/index.ejs"),
filename: "popup/index.html",
chunks: ["popup/polyfills", "popup/vendor-angular", "popup/vendor", "popup/main"],
browser: browser,
}),
new HtmlWebpackPlugin({
template: "./src/autofill/notification/bar.html",
template: path.resolve(__dirname, "src/autofill/notification/bar.html"),
filename: "notification/bar.html",
chunks: ["notification/bar"],
}),
new HtmlWebpackPlugin({
template: "./src/autofill/overlay/inline-menu/pages/button/button.html",
template: path.resolve(
__dirname,
"src/autofill/overlay/inline-menu/pages/button/button.html",
),
filename: "overlay/menu-button.html",
chunks: ["overlay/menu-button"],
}),
new HtmlWebpackPlugin({
template: "./src/autofill/overlay/inline-menu/pages/list/list.html",
template: path.resolve(__dirname, "src/autofill/overlay/inline-menu/pages/list/list.html"),
filename: "overlay/menu-list.html",
chunks: ["overlay/menu-list"],
}),
new HtmlWebpackPlugin({
template: "./src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html",
template: path.resolve(
__dirname,
"src/autofill/overlay/inline-menu/pages/menu-container/menu-container.html",
),
filename: "overlay/menu.html",
chunks: ["overlay/menu"],
}),
new CopyWebpackPlugin({
patterns: [
{
from: manifestVersion == 3 ? "./src/manifest.v3.json" : "./src/manifest.json",
from:
manifestVersion == 3
? path.resolve(__dirname, "src/manifest.v3.json")
: path.resolve(__dirname, "src/manifest.json"),
to: "manifest.json",
transform: manifest.transform(browser),
},
{ from: "./src/managed_schema.json", to: "managed_schema.json" },
{ from: "./src/_locales", to: "_locales" },
{ from: "./src/images", to: "images" },
{ from: "./src/popup/images", to: "popup/images" },
{ from: "./src/autofill/content/autofill.css", to: "content" },
{ from: path.resolve(__dirname, "src/managed_schema.json"), to: "managed_schema.json" },
{ from: path.resolve(__dirname, "src/_locales"), to: "_locales" },
{ from: path.resolve(__dirname, "src/images"), to: "images" },
{ from: path.resolve(__dirname, "src/popup/images"), to: "popup/images" },
{ from: path.resolve(__dirname, "src/autofill/content/autofill.css"), to: "content" },
],
}),
new MiniCssExtractPlugin({
@@ -196,33 +214,76 @@ module.exports.buildConfig = function buildConfig(params) {
name: "main",
mode: ENV,
devtool: false,
entry: {
"popup/polyfills": "./src/popup/polyfills.ts",
"popup/polyfills": path.resolve(__dirname, "src/popup/polyfills.ts"),
"popup/main": params.popup.entry,
"content/trigger-autofill-script-injection":
"./src/autofill/content/trigger-autofill-script-injection.ts",
"content/bootstrap-autofill": "./src/autofill/content/bootstrap-autofill.ts",
"content/bootstrap-autofill-overlay": "./src/autofill/content/bootstrap-autofill-overlay.ts",
"content/bootstrap-autofill-overlay-menu":
"./src/autofill/content/bootstrap-autofill-overlay-menu.ts",
"content/bootstrap-autofill-overlay-notifications":
"./src/autofill/content/bootstrap-autofill-overlay-notifications.ts",
"content/autofiller": "./src/autofill/content/autofiller.ts",
"content/auto-submit-login": "./src/autofill/content/auto-submit-login.ts",
"content/contextMenuHandler": "./src/autofill/content/context-menu-handler.ts",
"content/content-message-handler": "./src/autofill/content/content-message-handler.ts",
"content/fido2-content-script": "./src/autofill/fido2/content/fido2-content-script.ts",
"content/fido2-page-script": "./src/autofill/fido2/content/fido2-page-script.ts",
"content/ipc-content-script": "./src/platform/ipc/content/ipc-content-script.ts",
"notification/bar": "./src/autofill/notification/bar.ts",
"overlay/menu-button":
"./src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts",
"overlay/menu-list":
"./src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts",
"overlay/menu":
"./src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts",
"content/send-on-installed-message": "./src/vault/content/send-on-installed-message.ts",
"content/send-popup-open-message": "./src/vault/content/send-popup-open-message.ts",
"content/trigger-autofill-script-injection": path.resolve(
__dirname,
"src/autofill/content/trigger-autofill-script-injection.ts",
),
"content/bootstrap-autofill": path.resolve(
__dirname,
"src/autofill/content/bootstrap-autofill.ts",
),
"content/bootstrap-autofill-overlay": path.resolve(
__dirname,
"src/autofill/content/bootstrap-autofill-overlay.ts",
),
"content/bootstrap-autofill-overlay-menu": path.resolve(
__dirname,
"src/autofill/content/bootstrap-autofill-overlay-menu.ts",
),
"content/bootstrap-autofill-overlay-notifications": path.resolve(
__dirname,
"src/autofill/content/bootstrap-autofill-overlay-notifications.ts",
),
"content/autofiller": path.resolve(__dirname, "src/autofill/content/autofiller.ts"),
"content/auto-submit-login": path.resolve(
__dirname,
"src/autofill/content/auto-submit-login.ts",
),
"content/contextMenuHandler": path.resolve(
__dirname,
"src/autofill/content/context-menu-handler.ts",
),
"content/content-message-handler": path.resolve(
__dirname,
"src/autofill/content/content-message-handler.ts",
),
"content/fido2-content-script": path.resolve(
__dirname,
"src/autofill/fido2/content/fido2-content-script.ts",
),
"content/fido2-page-script": path.resolve(
__dirname,
"src/autofill/fido2/content/fido2-page-script.ts",
),
"content/ipc-content-script": path.resolve(
__dirname,
"src/platform/ipc/content/ipc-content-script.ts",
),
"notification/bar": path.resolve(__dirname, "src/autofill/notification/bar.ts"),
"overlay/menu-button": path.resolve(
__dirname,
"src/autofill/overlay/inline-menu/pages/button/bootstrap-autofill-inline-menu-button.ts",
),
"overlay/menu-list": path.resolve(
__dirname,
"src/autofill/overlay/inline-menu/pages/list/bootstrap-autofill-inline-menu-list.ts",
),
"overlay/menu": path.resolve(
__dirname,
"src/autofill/overlay/inline-menu/pages/menu-container/bootstrap-autofill-inline-menu-container.ts",
),
"content/send-on-installed-message": path.resolve(
__dirname,
"src/vault/content/send-on-installed-message.ts",
),
"content/send-popup-open-message": path.resolve(
__dirname,
"src/vault/content/send-popup-open-message.ts",
),
...params.additionalEntries,
},
cache:
@@ -291,7 +352,7 @@ module.exports.buildConfig = function buildConfig(params) {
resolve: {
extensions: [".ts", ".js"],
symlinks: false,
modules: [path.resolve("../../node_modules")],
modules: [path.resolve(__dirname, "../../node_modules")],
fallback: {
assert: false,
buffer: require.resolve("buffer/"),
@@ -306,7 +367,7 @@ module.exports.buildConfig = function buildConfig(params) {
filename: "[name].js",
chunkFilename: "assets/[name].js",
webassemblyModuleFilename: "assets/[modulehash].wasm",
path: path.resolve(__dirname, "build"),
path: params.outputPath,
clean: true,
},
module: {
@@ -335,7 +396,7 @@ module.exports.buildConfig = function buildConfig(params) {
// Manifest V2 uses Background Pages which requires a html page.
mainConfig.plugins.push(
new HtmlWebpackPlugin({
template: "./src/platform/background.html",
template: path.resolve(__dirname, "src/platform/background.html"),
filename: "background.html",
chunks: ["vendor", "background"],
}),
@@ -344,19 +405,23 @@ module.exports.buildConfig = function buildConfig(params) {
// Manifest V2 background pages can be run through the regular build pipeline.
// Since it's a standard webpage.
mainConfig.entry.background = params.background.entry;
mainConfig.entry["content/fido2-page-script-delay-append-mv2"] =
"./src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts";
mainConfig.entry["content/fido2-page-script-delay-append-mv2"] = path.resolve(
__dirname,
"src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts",
);
configs.push(mainConfig);
} else {
// Firefox does not use the offscreen API
if (browser !== "firefox") {
mainConfig.entry["offscreen-document/offscreen-document"] =
"./src/platform/offscreen-document/offscreen-document.ts";
mainConfig.entry["offscreen-document/offscreen-document"] = path.resolve(
__dirname,
"src/platform/offscreen-document/offscreen-document.ts",
);
mainConfig.plugins.push(
new HtmlWebpackPlugin({
template: "./src/platform/offscreen-document/index.html",
template: path.resolve(__dirname, "src/platform/offscreen-document/index.html"),
filename: "offscreen-document/index.html",
chunks: ["offscreen-document/offscreen-document"],
}),
@@ -372,11 +437,12 @@ module.exports.buildConfig = function buildConfig(params) {
name: "background",
mode: ENV,
devtool: false,
entry: params.background.entry,
target: target,
output: {
filename: "background.js",
path: path.resolve(__dirname, "build"),
path: params.outputPath,
},
module: {
rules: [
@@ -409,7 +475,7 @@ module.exports.buildConfig = function buildConfig(params) {
resolve: {
extensions: [".ts", ".js"],
symlinks: false,
modules: [path.resolve("../../node_modules")],
modules: [path.resolve(__dirname, "../../node_modules")],
plugins: [new TsconfigPathsPlugin()],
fallback: {
fs: false,
@@ -428,8 +494,11 @@ module.exports.buildConfig = function buildConfig(params) {
backgroundConfig.plugins.push(
new CopyWebpackPlugin({
patterns: [
{ from: "./src/safari/mv3/fake-background.html", to: "background.html" },
{ from: "./src/safari/mv3/fake-vendor.js", to: "vendor.js" },
{
from: path.resolve(__dirname, "src/safari/mv3/fake-background.html"),
to: "background.html",
},
{ from: path.resolve(__dirname, "src/safari/mv3/fake-vendor.js"), to: "vendor.js" },
],
}),
);

View File

@@ -1,13 +1,54 @@
const path = require("path");
const { buildConfig } = require("./webpack.base");
module.exports = buildConfig({
configName: "OSS",
popup: {
entry: "./src/popup/main.ts",
entryModule: "src/popup/app.module#AppModule",
},
background: {
entry: "./src/platform/background.ts",
},
tsConfig: "tsconfig.json",
});
module.exports = (webpackConfig, context) => {
// Detect if called by Nx (context parameter exists)
const isNxBuild = context && context.options;
if (isNxBuild) {
// Nx build configuration
const mode = context.options.mode || "development";
if (process.env.NODE_ENV == null) {
process.env.NODE_ENV = mode;
}
const ENV = (process.env.ENV = process.env.NODE_ENV);
// Set environment variables from Nx context
if (context.options.env) {
Object.keys(context.options.env).forEach((key) => {
process.env[key] = context.options.env[key];
});
}
return buildConfig({
configName: "OSS",
popup: {
entry: path.resolve(__dirname, "src/popup/main.ts"),
entryModule: "src/popup/app.module#AppModule",
},
background: {
entry: path.resolve(__dirname, "src/platform/background.ts"),
},
tsConfig: path.resolve(__dirname, "tsconfig.json"),
outputPath:
context.context && context.context.root
? path.resolve(context.context.root, context.options.outputPath)
: context.options.outputPath,
mode: mode,
env: ENV,
});
} else {
// npm build configuration
return buildConfig({
configName: "OSS",
popup: {
entry: path.resolve(__dirname, "src/popup/main.ts"),
entryModule: "src/popup/app.module#AppModule",
},
background: {
entry: path.resolve(__dirname, "src/platform/background.ts"),
},
tsConfig: "tsconfig.json",
});
}
};

View File

@@ -211,6 +211,7 @@ export class OssServeConfigurator {
this.serviceContainer.sendService,
this.serviceContainer.sendApiService,
this.serviceContainer.environmentService,
this.serviceContainer.accountService,
);
}

View File

@@ -73,6 +73,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 {
DefaultVaultTimeoutService,
DefaultVaultTimeoutSettingsService,
@@ -305,6 +307,7 @@ export class ServiceContainer {
cipherEncryptionService: CipherEncryptionService;
restrictedItemTypesService: RestrictedItemTypesService;
cliRestrictedItemTypesService: CliRestrictedItemTypesService;
securityStateService: SecurityStateService;
cipherArchiveService: CipherArchiveService;
constructor() {
@@ -406,6 +409,8 @@ export class ServiceContainer {
this.derivedStateProvider,
);
this.securityStateService = new DefaultSecurityStateService(this.stateProvider);
this.environmentService = new DefaultEnvironmentService(
this.stateProvider,
this.accountService,
@@ -547,6 +552,7 @@ export class ServiceContainer {
this.sendStateProvider = new SendStateProvider(this.stateProvider);
this.sendService = new SendService(
this.accountService,
this.keyService,
this.i18nService,
this.keyGenerationService,
@@ -612,6 +618,7 @@ export class ServiceContainer {
this.accountService,
this.kdfConfigService,
this.keyService,
this.securityStateService,
this.apiService,
this.stateProvider,
this.configService,
@@ -818,6 +825,7 @@ export class ServiceContainer {
this.tokenService,
this.authService,
this.stateProvider,
this.securityStateService,
);
this.totpService = new TotpService(this.sdkService);

View File

@@ -6,6 +6,7 @@ import * as path from "path";
import { firstValueFrom, switchMap } from "rxjs";
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/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
@@ -142,7 +143,8 @@ export class SendCreateCommand {
await this.sendApiService.save([encSend, fileData]);
const newSend = await this.sendService.getFromState(encSend.id);
const decSend = await newSend.decrypt();
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const decSend = await newSend.decrypt(activeUserId);
const env = await firstValueFrom(this.environmentService.environment$);
const res = new SendResponse(decSend, env.getWebVaultUrl());
return Response.success(res);

View File

@@ -3,6 +3,7 @@
import { firstValueFrom } from "rxjs";
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/account/billing-account-profile-state.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
@@ -83,7 +84,8 @@ export class SendEditCommand {
return Response.error("Premium status is required to use this feature.");
}
let sendView = await send.decrypt();
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
let sendView = await send.decrypt(activeUserId);
sendView = SendResponse.toView(req, sendView);
try {

View File

@@ -12,6 +12,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { isGuid } from "@bitwarden/guid";
import { DownloadCommand } from "../../../commands/download.command";
import { Response } from "../../../models/response";
@@ -74,13 +75,13 @@ export class SendGetCommand extends DownloadCommand {
}
private async getSendView(id: string): Promise<SendView | SendView[]> {
if (Utils.isGuid(id)) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
if (isGuid(id)) {
const send = await this.sendService.getFromState(id);
if (send != null) {
return await send.decrypt();
return await send.decrypt(activeUserId);
}
} else if (id.trim() !== "") {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
let sends = await this.sendService.getAllDecryptedFromState(activeUserId);
sends = this.searchService.searchSends(sends, id);
if (sends.length > 1) {

View File

@@ -2,6 +2,8 @@
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { SendService } from "@bitwarden/common/tools/send/services//send.service.abstraction";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
@@ -14,6 +16,7 @@ export class SendRemovePasswordCommand {
private sendService: SendService,
private sendApiService: SendApiService,
private environmentService: EnvironmentService,
private accountService: AccountService,
) {}
async run(id: string) {
@@ -21,7 +24,8 @@ export class SendRemovePasswordCommand {
await this.sendApiService.removePassword(id);
const updatedSend = await firstValueFrom(this.sendService.get$(id));
const decSend = await updatedSend.decrypt();
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const decSend = await updatedSend.decrypt(activeUserId);
const env = await firstValueFrom(this.environmentService.environment$);
const webVaultUrl = env.getWebVaultUrl();
const res = new SendResponse(decSend, webVaultUrl);

View File

@@ -297,6 +297,7 @@ export class SendProgram extends BaseProgram {
this.serviceContainer.sendService,
this.serviceContainer.sendApiService,
this.serviceContainer.environmentService,
this.serviceContainer.accountService,
);
const response = await cmd.run(id);
this.processResponse(response);

View File

@@ -754,6 +754,22 @@ dependencies = [
"syn",
]
[[package]]
name = "ctor"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb"
dependencies = [
"ctor-proc-macro",
"dtor",
]
[[package]]
name = "ctor-proc-macro"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
[[package]]
name = "ctr"
version = "0.9.2"
@@ -1049,6 +1065,21 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dtor"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e58a0764cddb55ab28955347b45be00ade43d4d6f3ba4bf3dc354e4ec9432934"
dependencies = [
"dtor-proc-macro",
]
[[package]]
name = "dtor-proc-macro"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
[[package]]
name = "ecdsa"
version = "0.16.9"
@@ -1793,13 +1824,14 @@ version = "0.0.0"
dependencies = [
"desktop_core",
"futures",
"log",
"oslog",
"serde",
"serde_json",
"tokio",
"tokio-util",
"tracing",
"tracing-oslog",
"tracing-subscriber",
"uniffi",
]
@@ -1886,7 +1918,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
dependencies = [
"bitflags",
"ctor",
"ctor 0.2.9",
"napi-derive",
"napi-sys",
"once_cell",
@@ -2559,6 +2591,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "process_isolation"
version = "0.0.0"
dependencies = [
"ctor 0.5.0",
"desktop_core",
"libc",
]
[[package]]
name = "quick-xml"
version = "0.37.5"
@@ -3445,6 +3486,18 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-oslog"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d76902d2a8d5f9f55a81155c08971734071968c90f2d9bfe645fe700579b2950"
dependencies = [
"cc",
"cfg-if",
"tracing-core",
"tracing-subscriber",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.20"

View File

@@ -6,6 +6,7 @@ members = [
"core",
"macos_provider",
"napi",
"process_isolation",
"proxy",
"windows_plugin_authenticator"
]
@@ -27,6 +28,7 @@ byteorder = "=1.5.0"
bytes = "=1.10.1"
cbc = "=0.1.2"
core-foundation = "=0.10.1"
ctor = "=0.5.0"
dirs = "=6.0.0"
ed25519 = "=2.2.3"
embed_plist = "=1.2.2"

View File

@@ -45,6 +45,20 @@ function buildProxyBin(target, release = true) {
}
}
function buildProcessIsolation() {
if (process.platform !== "linux") {
return;
}
child_process.execSync(`cargo build --release`, {
stdio: 'inherit',
cwd: path.join(__dirname, "process_isolation")
});
console.log("Copying process isolation library to dist folder");
fs.copyFileSync(path.join(__dirname, "target", "release", "libprocess_isolation.so"), path.join(__dirname, "dist", `libprocess_isolation.so`));
}
function installTarget(target) {
child_process.execSync(`rustup target add ${target}`, { stdio: 'inherit', cwd: __dirname });
}
@@ -53,6 +67,7 @@ if (!crossPlatform && !target) {
console.log(`Building native modules in ${mode} mode for the native architecture`);
buildNapiModule(false, mode === "release");
buildProxyBin(false, mode === "release");
buildProcessIsolation();
return;
}
@@ -61,6 +76,7 @@ if (target) {
installTarget(target);
buildNapiModule(target, mode === "release");
buildProxyBin(target, mode === "release");
buildProcessIsolation();
return;
}
@@ -78,4 +94,5 @@ platformTargets.forEach(([target, _]) => {
installTarget(target);
buildNapiModule(target);
buildProxyBin(target);
buildProcessIsolation();
});

View File

@@ -16,12 +16,13 @@ bench = false
[dependencies]
desktop_core = { path = "../core" }
futures = { workspace = true }
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
tokio-util = { workspace = true }
tracing = { workspace = true }
tracing-oslog = "0.3.0"
tracing-subscriber = { workspace = true }
uniffi = { workspace = true, features = ["cli"] }
[target.'cfg(target_os = "macos")'.dependencies]

View File

@@ -9,6 +9,11 @@ use std::{
use futures::FutureExt;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use tracing::{error, info};
use tracing_subscriber::{
filter::{EnvFilter, LevelFilter},
layer::SubscriberExt,
util::SubscriberInitExt,
};
uniffi::setup_scaffolding!();
@@ -88,8 +93,17 @@ impl MacOSProviderClient {
#[allow(clippy::unwrap_used)]
#[uniffi::constructor]
pub fn connect() -> Self {
let _ = oslog::OsLogger::new("com.bitwarden.desktop.autofill-extension")
.level_filter(log::LevelFilter::Trace)
let filter = EnvFilter::builder()
// Everything logs at `INFO`
.with_default_directive(LevelFilter::INFO.into())
.from_env_lossy();
tracing_subscriber::registry()
.with(filter)
.with(tracing_oslog::OsLogger::new(
"com.bitwarden.desktop.autofill-extension",
"default",
))
.init();
let (from_server_send, mut from_server_recv) = tokio::sync::mpsc::channel(32);

View File

@@ -0,0 +1,14 @@
[package]
name = "process_isolation"
edition = { workspace = true }
license = { workspace = true }
version = { workspace = true }
publish = { workspace = true }
[lib]
crate-type = ["cdylib"]
[dependencies]
ctor = { workspace = true }
desktop_core = { path = "../core" }
libc = { workspace = true }

View File

@@ -0,0 +1,46 @@
#![cfg(target_os = "linux")]
//! This library compiles to a pre-loadable shared object. When preloaded, it
//! immediately isolates the process using the methods available on the platform.
//! On Linux, this is PR_SET_DUMPABLE to prevent debuggers from attaching, the env
//! from being read and the memory from being stolen.
use desktop_core::process_isolation;
use std::{ffi::c_char, sync::LazyLock};
static ORIGINAL_UNSETENV: LazyLock<unsafe extern "C" fn(*const c_char) -> i32> =
LazyLock::new(|| unsafe {
std::mem::transmute(libc::dlsym(libc::RTLD_NEXT, c"unsetenv".as_ptr()))
});
/// Hooks unsetenv to fix a bug in zypak-wrapper.
/// Zypak unsets the env in Flatpak as a side-effect, which means that only the top level
/// processes would be hooked. With this work-around all processes in the tree are hooked
#[unsafe(no_mangle)]
unsafe extern "C" fn unsetenv(name: *const c_char) -> i32 {
unsafe {
let Ok(name_str) = std::ffi::CStr::from_ptr(name).to_str() else {
return ORIGINAL_UNSETENV(name);
};
if name_str == "LD_PRELOAD" {
// This env variable is provided by the flatpak configuration
let ld_preload = std::env::var("PROCESS_ISOLATION_LD_PRELOAD").unwrap_or_default();
std::env::set_var("LD_PRELOAD", ld_preload);
return 0;
}
ORIGINAL_UNSETENV(name)
}
}
// Hooks the shared object being loaded into the process
#[ctor::ctor]
fn preload_init() {
let pid = unsafe { libc::getpid() };
unsafe {
println!("[Process Isolation] Enabling memory security for process {pid}");
process_isolation::isolate_process();
process_isolation::disable_coredumps();
}
}

View File

@@ -0,0 +1,40 @@
#!/bin/bash
# This script tests the memory isolation status of bitwarden-desktop processes. The script will print "isolated"
# if the memory is not accessible by other processes.
CURRENT_USER=$(whoami)
# Find processes with "bitwarden" in the command
pids=$(pgrep -f bitwarden)
if [[ -z "$pids" ]]; then
echo "No bitwarden processes found."
exit 0
fi
for pid in $pids; do
# Get process info: command, PPID, RSS memory
read cmd ppid rss <<<$(ps -o comm=,ppid=,rss= -p "$pid")
# Explicitly skip if the command line does not contain "bitwarden"
if ! grep -q "bitwarden" <<<"$cmd"; then
continue
fi
# Check ownership of /proc/$pid/environ
owner=$(stat -c "%U" /proc/$pid/environ 2>/dev/null)
if [[ "$owner" == "root" ]]; then
status="isolated"
elif [[ "$owner" == "$CURRENT_USER" ]]; then
status="insecure"
else
status="unknown-owner:$owner"
fi
# Convert memory to MB
mem_mb=$((rss / 1024))
echo "PID: $pid | CMD: $cmd | Mem: ${mem_mb}MB | Owner: $owner | Status: $status"
done

View File

@@ -0,0 +1,132 @@
{
"extraMetadata": {
"name": "bitwarden-beta"
},
"productName": "Bitwarden Beta",
"appId": "com.bitwarden.desktop.beta",
"buildDependenciesFromSource": true,
"copyright": "Copyright © 2015-2025 Bitwarden Inc.",
"directories": {
"buildResources": "resources",
"output": "dist",
"app": "build"
},
"afterSign": "scripts/after-sign.js",
"afterPack": "scripts/after-pack.js",
"asarUnpack": ["**/*.node"],
"files": [
"**/*",
"!**/node_modules/@bitwarden/desktop-napi/**/*",
"**/node_modules/@bitwarden/desktop-napi/index.js",
"**/node_modules/@bitwarden/desktop-napi/desktop_napi.${platform}-${arch}*.node"
],
"electronVersion": "36.8.1",
"generateUpdatesFilesForAllChannels": true,
"publish": {
"provider": "generic",
"url": "https://artifacts.bitwarden.com/desktop"
},
"win": {
"electronUpdaterCompatibility": ">=0.0.1",
"target": ["portable", "nsis-web", "appx"],
"signtoolOptions": {
"sign": "./sign.js"
},
"extraFiles": [
{
"from": "desktop_native/dist/desktop_proxy.${platform}-${arch}.exe",
"to": "desktop_proxy.exe"
}
]
},
"nsisWeb": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": false,
"artifactName": "Bitwarden-Beta-Installer-${version}.${ext}",
"uninstallDisplayName": "${productName}",
"deleteAppDataOnUninstall": true,
"include": "installer.nsh"
},
"portable": {
"artifactName": "Bitwarden-Beta-Portable-${version}.${ext}"
},
"appx": {
"artifactName": "Bitwarden-Beta-${version}-${arch}.${ext}",
"backgroundColor": "#175DDC",
"applicationId": "BitwardenBeta",
"identityName": "8bitSolutionsLLC.BitwardenBeta",
"publisher": "CN=14D52771-DE3C-4886-B8BF-825BA7690418",
"publisherDisplayName": "Bitwarden Inc",
"languages": [
"en-US",
"af",
"ar",
"az-latn",
"be",
"bg",
"bn",
"bs",
"ca",
"cs",
"cy",
"da",
"de",
"el",
"en-gb",
"en-in",
"es",
"et",
"eu",
"fa",
"fi",
"fil",
"fr",
"gl",
"he",
"hi",
"hr",
"hu",
"id",
"it",
"ja",
"ka",
"km",
"kn",
"ko",
"lt",
"lv",
"ml",
"mr",
"nb",
"ne",
"nl",
"nn",
"or",
"pl",
"pt-br",
"pt-pt",
"ro",
"ru",
"si",
"sk",
"sl",
"sr-cyrl",
"sv",
"ta",
"te",
"th",
"tr",
"uk",
"vi",
"zh-cn",
"zh-tw"
]
},
"protocols": [
{
"name": "Bitwarden",
"schemes": ["bitwarden"]
}
]
}

View File

@@ -106,6 +106,10 @@
{
"from": "desktop_native/dist/desktop_proxy.${platform}-${arch}",
"to": "desktop_proxy"
},
{
"from": "desktop_native/dist/libprocess_isolation.so",
"to": "libprocess_isolation.so"
}
],
"target": ["deb", "freebsd", "rpm", "AppImage", "snap"],

View File

@@ -36,9 +36,10 @@
"build:renderer:watch": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer --watch",
"electron": "node ./scripts/start.js",
"electron:ignore": "node ./scripts/start.js --ignore-certificate-errors",
"flatpak:dev": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --force-clean --install --user ../../.flatpak/ ./resources/com.bitwarden.desktop.devel.yaml && flatpak run com.bitwarden.desktop",
"clean:dist": "rimraf ./dist",
"pack:dir": "npm run clean:dist && electron-builder --dir -p never",
"pack:lin:flatpak": "npm run clean:dist && electron-builder --dir -p never && flatpak-builder --repo=build/.repo build/.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ./build/.repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop",
"pack:lin:flatpak": "flatpak-builder --repo=../../.flatpak-repo ../../.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ../../.flatpak-repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop",
"pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/",
"pack:lin:arm64": "npm run clean:dist && electron-builder --dir -p never && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .",
"pack:mac": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never",
@@ -47,6 +48,7 @@
"pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never",
"pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"",
"pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"",
"pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"",
"pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never",
"dist:dir": "npm run build && npm run pack:dir",
"dist:lin": "npm run build && npm run pack:lin",

115
apps/desktop/project.json Normal file
View File

@@ -0,0 +1,115 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"name": "desktop",
"projectType": "application",
"sourceRoot": "apps/desktop/src",
"tags": ["scope:desktop", "type:app"],
"targets": {
"build-native": {
"executor": "nx:run-commands",
"options": {
"command": "cd desktop_native && node build.js",
"cwd": "apps/desktop"
}
},
"build-main": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/dist/apps/desktop"],
"options": {
"command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main",
"cwd": "apps/desktop"
},
"configurations": {
"development": {
"command": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name main"
},
"production": {
"command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name main"
}
}
},
"build-preload": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/dist/apps/desktop"],
"options": {
"command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name preload",
"cwd": "apps/desktop"
},
"configurations": {
"development": {
"command": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name preload"
},
"production": {
"command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name preload"
}
}
},
"build-renderer": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/dist/apps/desktop"],
"options": {
"command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer",
"cwd": "apps/desktop"
},
"configurations": {
"development": {
"command": "cross-env NODE_ENV=development webpack --config webpack.config.js --config-name renderer"
},
"production": {
"command": "cross-env NODE_ENV=production webpack --config webpack.config.js --config-name renderer"
}
}
},
"build": {
"executor": "nx:run-commands",
"dependsOn": ["build-native"],
"outputs": ["{workspaceRoot}/dist/apps/desktop"],
"options": {
"parallel": true,
"commands": [
"nx run desktop:build-main",
"nx run desktop:build-preload",
"nx run desktop:build-renderer"
]
},
"configurations": {
"development": {
"commands": [
"nx run desktop:build-main --configuration=development",
"nx run desktop:build-preload --configuration=development",
"nx run desktop:build-renderer --configuration=development"
]
},
"production": {
"commands": [
"nx run desktop:build-main --configuration=production",
"nx run desktop:build-preload --configuration=production",
"nx run desktop:build-renderer --configuration=production"
]
}
}
},
"serve": {
"executor": "nx:run-commands",
"dependsOn": ["build-native"],
"options": {
"command": "node scripts/nx-serve.js",
"cwd": "apps/desktop"
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/apps/desktop"],
"options": {
"jestConfig": "apps/desktop/jest.config.js"
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["apps/desktop/**/*.ts", "apps/desktop/**/*.html"]
}
}
}
}

View File

@@ -46,4 +46,6 @@ modules:
commands:
- ulimit -c 0
- export TMPDIR="$XDG_RUNTIME_DIR/app/$FLATPAK_ID"
- export ZYPAK_LD_PRELOAD="/app/bin/libprocess_isolation.so"
- export PROCESS_ISOLATION_LD_PRELOAD="/app/bin/libprocess_isolation.so"
- exec zypak-wrapper /app/bin/bitwarden-app "$@"

View File

@@ -7,12 +7,19 @@ ulimit -c 0
RAW_PATH=$(readlink -f "$0")
APP_PATH=$(dirname $RAW_PATH)
# force use of base image libdus in snap
if [ -e "/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" ]
then
# force use of base image libdbus in snap
if [ -e "/usr/lib/x86_64-linux-gnu/libdbus-1.so.3" ]; then
export LD_PRELOAD="/usr/lib/x86_64-linux-gnu/libdbus-1.so.3"
fi
# If running in non-snap, add libprocess_isolation.so from app path to LD_PRELOAD
# This prevents debugger / memory dumping on all desktop processes
if [ -z "$SNAP" ] && [ -f "$APP_PATH/libprocess_isolation.so" ]; then
LIBPROCESS_ISOLATION_SO="$APP_PATH/libprocess_isolation.so"
LD_PRELOAD="$LIBPROCESS_ISOLATION_SO${LD_PRELOAD:+:$LD_PRELOAD}"
export LD_PRELOAD
fi
PARAMS="--enable-features=UseOzonePlatform,WaylandWindowDecorations --ozone-platform-hint=auto"
if [ "$USE_X11" = "true" ]; then
PARAMS=""

View File

@@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const path = require("path");
const concurrently = require("concurrently");
const rimraf = require("rimraf");
const args = process.argv.splice(2);
const outputPath = path.resolve(__dirname, "../../../dist/apps/desktop");
rimraf.sync(outputPath);
require("fs").mkdirSync(outputPath, { recursive: true });
concurrently(
[
{
name: "Main",
command: `cross-env NODE_ENV=development OUTPUT_PATH=${outputPath} webpack --config webpack.config.js --config-name main --watch`,
prefixColor: "yellow",
},
{
name: "Prel",
command: `cross-env NODE_ENV=development OUTPUT_PATH=${outputPath} webpack --config webpack.config.js --config-name preload --watch`,
prefixColor: "magenta",
},
{
name: "Rend",
command: `cross-env NODE_ENV=development OUTPUT_PATH=${outputPath} webpack --config webpack.config.js --config-name renderer --watch`,
prefixColor: "cyan",
},
{
name: "Elec",
command: `npx wait-on ${outputPath}/main.js ${outputPath}/index.html && npx electron --no-sandbox --inspect=5858 ${args.join(
" ",
)} ${outputPath} --watch`,
prefixColor: "green",
},
],
{
prefix: "name",
outputStream: process.stdout,
killOthers: ["success", "failure"],
},
);

View File

@@ -13,7 +13,7 @@ exports.default = async function (configuration) {
`-fd ${configuration.hash} ` +
`-du ${configuration.site} ` +
`-tr http://timestamp.digicert.com ` +
`${configuration.path}`,
`"${configuration.path}"`,
{
stdio: "inherit",
},

View File

@@ -3,11 +3,13 @@
import { CommonModule, DatePipe } from "@angular/common";
import { Component } from "@angular/core";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
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/account/billing-account-profile-state.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -63,7 +65,8 @@ export class AddEditComponent extends BaseAddEditComponent {
async refresh() {
const send = await this.loadSend();
this.send = await send.decrypt();
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.send = await send.decrypt(userId);
this.updateFormValues();
this.hasPassword = this.send.password != null && this.send.password.trim() !== "";
}

View File

@@ -4224,7 +4224,7 @@
"message": "Archive",
"description": "Verb"
},
"unarchive": {
"unArchive": {
"message": "Unarchive"
},
"itemsInArchive": {
@@ -4236,11 +4236,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"

View File

@@ -12,10 +12,13 @@ import { MessageSender } from "@bitwarden/common/platform/messaging";
/**
* The SSO Localhost login service uses a local host listener as fallback in case scheme handling deeplinks does not work.
* This way it is possible to log in with SSO on appimage, snap, and electron dev using the same methods that the cli uses.
* This way it is possible to log in with SSO on appimage and electron dev using the same methods that the cli uses.
*/
export class SSOLocalhostCallbackService {
private ssoRedirectUri = "";
// We will only track one server at a time for use-case and performance considerations.
// This will result in a last-one-wins behavior if multiple SSO flows are started simultaneously.
private currentServer: http.Server | null = null;
constructor(
private environmentService: EnvironmentService,
@@ -23,11 +26,30 @@ export class SSOLocalhostCallbackService {
private ssoUrlService: SsoUrlService,
) {
ipcMain.handle("openSsoPrompt", async (event, { codeChallenge, state, email }) => {
const { ssoCode, recvState } = await this.openSsoPrompt(codeChallenge, state, email);
this.messagingService.send("ssoCallback", {
code: ssoCode,
state: recvState,
redirectUri: this.ssoRedirectUri,
// Close any existing server before starting new one
if (this.currentServer) {
await this.closeCurrentServer();
}
return this.openSsoPrompt(codeChallenge, state, email).then(({ ssoCode, recvState }) => {
this.messagingService.send("ssoCallback", {
code: ssoCode,
state: recvState,
redirectUri: this.ssoRedirectUri,
});
});
});
}
private async closeCurrentServer(): Promise<void> {
if (!this.currentServer) {
return;
}
return new Promise<void>((resolve) => {
this.currentServer!.close(() => {
this.currentServer = null;
resolve();
});
});
}
@@ -59,6 +81,7 @@ export class SSOLocalhostCallbackService {
"<p>You may now close this tab and return to the app.</p>" +
"</body></html>",
);
this.currentServer = null;
callbackServer.close(() =>
resolve({
ssoCode: code,
@@ -73,41 +96,68 @@ export class SSOLocalhostCallbackService {
"<p>You may now close this tab and return to the app.</p>" +
"</body></html>",
);
this.currentServer = null;
callbackServer.close(() => reject());
}
});
let foundPort = false;
const webUrl = env.getWebVaultUrl();
for (let port = 8065; port <= 8070; port++) {
try {
this.ssoRedirectUri = "http://localhost:" + port;
const ssoUrl = this.ssoUrlService.buildSsoUrl(
webUrl,
ClientType.Desktop,
this.ssoRedirectUri,
state,
codeChallenge,
email,
);
callbackServer.listen(port, () => {
this.messagingService.send("launchUri", {
url: ssoUrl,
});
});
foundPort = true;
break;
} catch {
// Ignore error since we run the same command up to 5 times.
}
}
if (!foundPort) {
reject();
}
// Store reference to current server
this.currentServer = callbackServer;
// after 5 minutes, close the server
const webUrl = env.getWebVaultUrl();
const tryNextPort = (port: number) => {
if (port > 8070) {
this.currentServer = null;
reject("All available SSO ports in use");
return;
}
this.ssoRedirectUri = "http://localhost:" + port;
const ssoUrl = this.ssoUrlService.buildSsoUrl(
webUrl,
ClientType.Desktop,
this.ssoRedirectUri,
state,
codeChallenge,
email,
);
// Set up error handler before attempting to listen
callbackServer.once("error", (err: any) => {
if (err.code === "EADDRINUSE") {
// Port is in use, try next port
tryNextPort(port + 1);
} else {
// Another error - reject and set the current server to null
// (one server alive at a time)
this.currentServer = null;
reject();
}
});
// Attempt to listen on the port
callbackServer.listen(port, () => {
// Success - remove error listener and launch SSO
callbackServer.removeAllListeners("error");
this.messagingService.send("launchUri", {
url: ssoUrl,
});
});
};
// Start trying from port 8065
tryNextPort(8065);
// Don't allow any server to stay up for more than 5 minutes;
// this gives plenty of time to complete SSO but ensures we don't
// have a server running indefinitely.
setTimeout(
() => {
if (this.currentServer === callbackServer) {
this.currentServer = null;
}
callbackServer.close(() => reject());
},
5 * 60 * 1000,

View File

@@ -53,7 +53,8 @@ export function isWindowsStore() {
if (
windows &&
!windowsStore &&
process.resourcesPath?.indexOf("8bitSolutionsLLC.bitwardendesktop_") > -1
(process.resourcesPath?.indexOf("8bitSolutionsLLC.bitwardendesktop_") > -1 ||
process.resourcesPath?.indexOf("8bitSolutionsLLC.BitwardenBeta_") > -1)
) {
windowsStore = true;
}

View File

@@ -723,9 +723,7 @@ export class VaultV2Component<C extends CipherViewLike>
this.cipherId = cipher.id;
this.cipher = cipher;
if (this.activeUserId) {
await this.cipherService.clearCache(this.activeUserId).catch(() => {});
}
await this.vaultItemsComponent?.load(this.activeFilter.buildFilter()).catch(() => {});
await this.go().catch(() => {});
await this.vaultItemsComponent?.refresh().catch(() => {});

View File

@@ -8,7 +8,7 @@ const { AngularWebpackPlugin } = require("@ngtools/webpack");
const TerserPlugin = require("terser-webpack-plugin");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
const { EnvironmentPlugin, DefinePlugin } = require("webpack");
const configurator = require("./config/config");
const configurator = require(path.resolve(__dirname, "config/config"));
module.exports.getEnv = function getEnv() {
const NODE_ENV = process.env.NODE_ENV == null ? "development" : process.env.NODE_ENV;
@@ -17,6 +17,14 @@ module.exports.getEnv = function getEnv() {
return { NODE_ENV, ENV };
};
const DEFAULT_PARAMS = {
outputPath: process.env.OUTPUT_PATH
? path.isAbsolute(process.env.OUTPUT_PATH)
? process.env.OUTPUT_PATH
: path.resolve(__dirname, process.env.OUTPUT_PATH)
: path.resolve(__dirname, "build"),
};
/**
* @param {{
* configName: string;
@@ -33,9 +41,11 @@ module.exports.getEnv = function getEnv() {
* entry: string;
* tsConfig: string;
* };
* outputPath?: string;
* }} params
*/
module.exports.buildConfig = function buildConfig(params) {
params = { ...DEFAULT_PARAMS, ...params };
const { NODE_ENV, ENV } = module.exports.getEnv();
console.log(`Building ${params.configName} Desktop App`);
@@ -47,13 +57,16 @@ module.exports.buildConfig = function buildConfig(params) {
resolve: {
extensions: [".tsx", ".ts", ".js"],
symlinks: false,
modules: [path.resolve("../../node_modules")],
modules: [
path.resolve(__dirname, "../../node_modules"),
path.resolve(process.cwd(), "node_modules"),
],
},
};
const getOutputConfig = (isDev) => ({
filename: "[name].js",
path: path.resolve(__dirname, "build"),
path: params.outputPath,
...(isDev && { devtoolModuleFilenameTemplate: "[absolute-resource-path]" }),
});
@@ -96,9 +109,9 @@ module.exports.buildConfig = function buildConfig(params) {
plugins: [
new CopyWebpackPlugin({
patterns: [
"./src/package.json",
{ from: "./src/images", to: "images" },
{ from: "./src/locales", to: "locales" },
path.resolve(__dirname, "src/package.json"),
{ from: path.resolve(__dirname, "src/images"), to: "images" },
{ from: path.resolve(__dirname, "src/locales"), to: "locales" },
],
}),
new DefinePlugin({
@@ -164,7 +177,7 @@ module.exports.buildConfig = function buildConfig(params) {
},
output: {
filename: "[name].js",
path: path.resolve(__dirname, "build"),
path: params.outputPath,
},
optimization: {
minimizer: [
@@ -200,7 +213,7 @@ module.exports.buildConfig = function buildConfig(params) {
{
loader: "babel-loader",
options: {
configFile: "../../babel.config.json",
configFile: path.resolve(__dirname, "../../babel.config.json"),
},
},
],
@@ -293,7 +306,7 @@ module.exports.buildConfig = function buildConfig(params) {
path.resolve(__dirname, "./src"),
),
new HtmlWebpackPlugin({
template: "./src/index.html",
template: path.resolve(__dirname, "src/index.html"),
filename: "index.html",
chunks: ["app/vendor", "app/main"],
}),

View File

@@ -1,18 +1,43 @@
const path = require("path");
const { buildConfig } = require("./webpack.base");
module.exports = buildConfig({
configName: "OSS",
renderer: {
entry: "./src/app/main.ts",
entryModule: "src/app/app.module#AppModule",
tsConfig: "./tsconfig.renderer.json",
},
main: {
entry: "./src/entry.ts",
tsConfig: "./tsconfig.json",
},
preload: {
entry: "./src/preload.ts",
tsConfig: "./tsconfig.json",
},
});
module.exports = (webpackConfig, context) => {
const isNxBuild = context && context.options;
if (isNxBuild) {
return buildConfig({
configName: "OSS",
renderer: {
entry: path.resolve(__dirname, "src/app/main.ts"),
entryModule: "src/app/app.module#AppModule",
tsConfig: path.resolve(context.context.root, "apps/desktop/tsconfig.renderer.json"),
},
main: {
entry: path.resolve(__dirname, "src/entry.ts"),
tsConfig: path.resolve(context.context.root, "apps/desktop/tsconfig.json"),
},
preload: {
entry: path.resolve(__dirname, "src/preload.ts"),
tsConfig: path.resolve(context.context.root, "apps/desktop/tsconfig.json"),
},
outputPath: path.resolve(context.context.root, context.options.outputPath),
});
} else {
return buildConfig({
configName: "OSS",
renderer: {
entry: path.resolve(__dirname, "src/app/main.ts"),
entryModule: "src/app/app.module#AppModule",
tsConfig: path.resolve(__dirname, "tsconfig.renderer.json"),
},
main: {
entry: path.resolve(__dirname, "src/entry.ts"),
tsConfig: path.resolve(__dirname, "tsconfig.json"),
},
preload: {
entry: path.resolve(__dirname, "src/preload.ts"),
tsConfig: path.resolve(__dirname, "tsconfig.json"),
},
});
}
};

View File

@@ -1,9 +1,13 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const path = require("path");
module.exports = {
plugins: [
require("postcss-import"),
require("postcss-import")({
path: [path.resolve(__dirname, "../../libs"), path.resolve(__dirname, "src/scss")],
}),
require("postcss-nested"),
require("tailwindcss"),
require("tailwindcss")({ config: path.resolve(__dirname, "tailwind.config.js") }),
require("autoprefixer"),
],
};

215
apps/web/project.json Normal file
View File

@@ -0,0 +1,215 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"name": "web",
"projectType": "application",
"sourceRoot": "apps/web/src",
"tags": ["scope:web", "type:app"],
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "oss",
"options": {
"outputPath": "dist/apps/web",
"webpackConfig": "apps/web/webpack.config.js",
"tsConfig": "apps/web/tsconfig.json",
"main": "apps/web/src/main.ts",
"target": "web",
"compiler": "tsc"
},
"configurations": {
"oss": {
"mode": "production",
"outputPath": "dist/apps/web/oss"
},
"oss-dev": {
"mode": "development",
"outputPath": "dist/apps/web/oss-dev",
"env": {
"NODE_ENV": "development",
"ENV": "development"
}
},
"commercial": {
"mode": "production",
"outputPath": "dist/apps/web/commercial",
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
"main": "bitwarden_license/bit-web/src/main.ts"
},
"commercial-dev": {
"mode": "development",
"outputPath": "dist/apps/web/commercial-dev",
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
"main": "bitwarden_license/bit-web/src/main.ts",
"env": {
"NODE_ENV": "development",
"ENV": "development"
}
},
"commercial-qa": {
"mode": "production",
"outputPath": "dist/apps/web/commercial-qa",
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
"main": "bitwarden_license/bit-web/src/main.ts",
"env": {
"NODE_ENV": "production",
"ENV": "qa"
}
},
"commercial-cloud": {
"mode": "production",
"outputPath": "dist/apps/web/commercial-cloud",
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
"main": "bitwarden_license/bit-web/src/main.ts",
"env": {
"NODE_ENV": "production",
"ENV": "cloud"
}
},
"commercial-euprd": {
"mode": "production",
"outputPath": "dist/apps/web/commercial-euprd",
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
"main": "bitwarden_license/bit-web/src/main.ts",
"env": {
"NODE_ENV": "production",
"ENV": "euprd"
}
},
"commercial-euqa": {
"mode": "production",
"outputPath": "dist/apps/web/commercial-euqa",
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
"main": "bitwarden_license/bit-web/src/main.ts",
"env": {
"NODE_ENV": "production",
"ENV": "euqa"
}
},
"commercial-usdev": {
"mode": "production",
"outputPath": "dist/apps/web/commercial-usdev",
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
"main": "bitwarden_license/bit-web/src/main.ts",
"env": {
"NODE_ENV": "production",
"ENV": "usdev"
}
},
"commercial-ee": {
"mode": "production",
"outputPath": "dist/apps/web/commercial-ee",
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
"main": "bitwarden_license/bit-web/src/main.ts",
"env": {
"NODE_ENV": "production",
"ENV": "ee"
}
},
"oss-selfhost": {
"mode": "production",
"outputPath": "dist/apps/web/oss-selfhost",
"env": {
"ENV": "selfhosted",
"NODE_ENV": "production"
}
},
"oss-selfhost-dev": {
"mode": "development",
"outputPath": "dist/apps/web/oss-selfhost-dev",
"env": {
"NODE_ENV": "development",
"ENV": "selfhosted"
}
},
"commercial-selfhost": {
"mode": "production",
"outputPath": "dist/apps/web/commercial-selfhost",
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
"main": "bitwarden_license/bit-web/src/main.ts",
"env": {
"ENV": "selfhosted",
"NODE_ENV": "production"
}
},
"commercial-selfhost-dev": {
"mode": "development",
"outputPath": "dist/apps/web/commercial-selfhost-dev",
"webpackConfig": "bitwarden_license/bit-web/webpack.config.js",
"main": "bitwarden_license/bit-web/src/main.ts",
"env": {
"NODE_ENV": "development",
"ENV": "selfhosted"
}
}
}
},
"serve": {
"executor": "@nx/webpack:dev-server",
"defaultConfiguration": "oss-dev",
"options": {
"buildTarget": "web:build",
"host": "localhost",
"port": 8080
},
"configurations": {
"oss": {
"buildTarget": "web:build:oss"
},
"oss-dev": {
"buildTarget": "web:build:oss-dev"
},
"commercial": {
"buildTarget": "web:build:commercial"
},
"commercial-dev": {
"buildTarget": "web:build:commercial-dev"
},
"commercial-qa": {
"buildTarget": "web:build:commercial-qa"
},
"commercial-cloud": {
"buildTarget": "web:build:commercial-cloud"
},
"commercial-euprd": {
"buildTarget": "web:build:commercial-euprd"
},
"commercial-euqa": {
"buildTarget": "web:build:commercial-euqa"
},
"commercial-usdev": {
"buildTarget": "web:build:commercial-usdev"
},
"commercial-ee": {
"buildTarget": "web:build:commercial-ee"
},
"oss-selfhost": {
"buildTarget": "web:build:oss-selfhost"
},
"oss-selfhost-dev": {
"buildTarget": "web:build:oss-selfhost-dev"
},
"commercial-selfhost": {
"buildTarget": "web:build:commercial-selfhost"
},
"commercial-selfhost-dev": {
"buildTarget": "web:build:commercial-selfhost-dev"
}
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/web/jest.config.js"
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["apps/web/**/*.ts", "apps/web/**/*.html"]
}
}
}
}

View File

@@ -47,6 +47,7 @@ import { OrganizationKeysRequest } from "@bitwarden/common/admin-console/models/
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/billing-api.service.abstraction";
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
import { isNotSelfUpgradable, ProductTierType } from "@bitwarden/common/billing/enums";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -146,6 +147,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
private groupService: GroupApiService,
private collectionService: CollectionService,
private billingApiService: BillingApiServiceAbstraction,
private organizationMetadataService: OrganizationMetadataServiceAbstraction,
protected deleteManagedMemberWarningService: DeleteManagedMemberWarningService,
private configService: ConfigService,
private organizationUserService: OrganizationUserService,
@@ -257,7 +259,7 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
this.billingMetadata$ = combineLatest([this.refreshBillingMetadata$, organization$]).pipe(
switchMap(([_, organization]) =>
this.billingApiService.getOrganizationBillingMetadata(organization.id),
this.organizationMetadataService.getOrganizationMetadata$(organization.id),
),
takeUntilDestroyed(),
shareReplay({ bufferSize: 1, refCount: false }),

View File

@@ -56,7 +56,9 @@ export class ProfileComponent implements OnInit, OnDestroy {
this.profile = await this.apiService.getProfile();
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.fingerprintMaterial = userId;
const publicKey = await firstValueFrom(this.keyService.userPublicKey$(userId));
const publicKey = (await firstValueFrom(
this.keyService.userPublicKey$(userId),
)) as UserPublicKey;
if (publicKey == null) {
this.logService.error(
"[ProfileComponent] No public key available for the user: " +

View File

@@ -1,6 +1,11 @@
@if (step() == PlanSelectionStep) {
<app-upgrade-account (planSelected)="onPlanSelected($event)" (closeClicked)="onCloseClicked()" />
} @else if (step() == PaymentStep && selectedPlan() !== null) {
<app-upgrade-account
[dialogTitleMessageOverride]="planSelectionStepTitleOverride()"
[hideContinueWithoutUpgradingButton]="hideContinueWithoutUpgradingButton()"
(planSelected)="onPlanSelected($event)"
(closeClicked)="onCloseClicked()"
/>
} @else if (step() == PaymentStep && selectedPlan() !== null && account() !== null) {
<app-upgrade-payment
[selectedPlanId]="selectedPlan()"
[account]="account()"

View File

@@ -0,0 +1,240 @@
import { Component, input, output } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
import { mock } from "jest-mock-extended";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { DIALOG_DATA, DialogRef } from "@bitwarden/components";
import {
PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds,
} from "../../../types/subscription-pricing-tier";
import {
UpgradeAccountComponent,
UpgradeAccountStatus,
} from "../upgrade-account/upgrade-account.component";
import {
UpgradePaymentComponent,
UpgradePaymentResult,
} from "../upgrade-payment/upgrade-payment.component";
import {
UnifiedUpgradeDialogComponent,
UnifiedUpgradeDialogParams,
UnifiedUpgradeDialogStep,
} from "./unified-upgrade-dialog.component";
@Component({
selector: "app-upgrade-account",
template: "",
standalone: true,
})
class MockUpgradeAccountComponent {
dialogTitleMessageOverride = input<string | null>(null);
hideContinueWithoutUpgradingButton = input<boolean>(false);
planSelected = output<PersonalSubscriptionPricingTierId>();
closeClicked = output<UpgradeAccountStatus>();
}
@Component({
selector: "app-upgrade-payment",
template: "",
standalone: true,
})
class MockUpgradePaymentComponent {
selectedPlanId = input<PersonalSubscriptionPricingTierId | null>(null);
account = input<Account | null>(null);
goBack = output<void>();
complete = output<UpgradePaymentResult>();
}
describe("UnifiedUpgradeDialogComponent", () => {
let component: UnifiedUpgradeDialogComponent;
let fixture: ComponentFixture<UnifiedUpgradeDialogComponent>;
const mockDialogRef = mock<DialogRef>();
const mockAccount: Account = {
id: "user-id" as UserId,
email: "test@example.com",
emailVerified: true,
name: "Test User",
};
const defaultDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: null,
selectedPlan: null,
planSelectionStepTitleOverride: null,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: defaultDialogData },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
fixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it("should create", () => {
expect(component).toBeTruthy();
});
it("should initialize with default values", () => {
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection);
expect(component["selectedPlan"]()).toBeNull();
expect(component["account"]()).toEqual(mockAccount);
expect(component["planSelectionStepTitleOverride"]()).toBeNull();
});
it("should initialize with custom initial step", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: UnifiedUpgradeDialogStep.Payment,
selectedPlan: PersonalSubscriptionPricingTierIds.Premium,
};
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
const customComponent = customFixture.componentInstance;
customFixture.detectChanges();
expect(customComponent["step"]()).toBe(UnifiedUpgradeDialogStep.Payment);
expect(customComponent["selectedPlan"]()).toBe(PersonalSubscriptionPricingTierIds.Premium);
});
describe("custom dialog title", () => {
it("should use null as default when no override is provided", () => {
expect(component["planSelectionStepTitleOverride"]()).toBeNull();
});
it("should use custom title when provided in dialog config", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: UnifiedUpgradeDialogStep.PlanSelection,
selectedPlan: null,
planSelectionStepTitleOverride: "upgradeYourPlan",
};
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
const customComponent = customFixture.componentInstance;
customFixture.detectChanges();
expect(customComponent["planSelectionStepTitleOverride"]()).toBe("upgradeYourPlan");
});
});
describe("onPlanSelected", () => {
it("should set selected plan and move to payment step", () => {
component["onPlanSelected"](PersonalSubscriptionPricingTierIds.Premium);
expect(component["selectedPlan"]()).toBe(PersonalSubscriptionPricingTierIds.Premium);
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.Payment);
});
});
describe("previousStep", () => {
it("should go back to plan selection and clear selected plan", () => {
component["step"].set(UnifiedUpgradeDialogStep.Payment);
component["selectedPlan"].set(PersonalSubscriptionPricingTierIds.Premium);
component["previousStep"]();
expect(component["step"]()).toBe(UnifiedUpgradeDialogStep.PlanSelection);
expect(component["selectedPlan"]()).toBeNull();
});
});
describe("hideContinueWithoutUpgradingButton", () => {
it("should default to false when not provided", () => {
expect(component["hideContinueWithoutUpgradingButton"]()).toBe(false);
});
it("should be set to true when provided in dialog config", async () => {
TestBed.resetTestingModule();
const customDialogData: UnifiedUpgradeDialogParams = {
account: mockAccount,
initialStep: null,
selectedPlan: null,
hideContinueWithoutUpgradingButton: true,
};
await TestBed.configureTestingModule({
imports: [NoopAnimationsModule, UnifiedUpgradeDialogComponent],
providers: [
{ provide: DialogRef, useValue: mockDialogRef },
{ provide: DIALOG_DATA, useValue: customDialogData },
],
})
.overrideComponent(UnifiedUpgradeDialogComponent, {
remove: {
imports: [UpgradeAccountComponent, UpgradePaymentComponent],
},
add: {
imports: [MockUpgradeAccountComponent, MockUpgradePaymentComponent],
},
})
.compileComponents();
const customFixture = TestBed.createComponent(UnifiedUpgradeDialogComponent);
const customComponent = customFixture.componentInstance;
customFixture.detectChanges();
expect(customComponent["hideContinueWithoutUpgradingButton"]()).toBe(true);
});
});
});

View File

@@ -48,11 +48,15 @@ export type UnifiedUpgradeDialogResult = {
* @property {Account} account - The user account information.
* @property {UnifiedUpgradeDialogStep | null} [initialStep] - The initial step to show in the dialog, if any.
* @property {PersonalSubscriptionPricingTierId | null} [selectedPlan] - Pre-selected subscription plan, if any.
* @property {string | null} [dialogTitleMessageOverride] - Optional custom i18n key to override the default dialog title.
* @property {boolean} [hideContinueWithoutUpgradingButton] - Whether to hide the "Continue without upgrading" button.
*/
export type UnifiedUpgradeDialogParams = {
account: Account;
initialStep?: UnifiedUpgradeDialogStep | null;
selectedPlan?: PersonalSubscriptionPricingTierId | null;
planSelectionStepTitleOverride?: string | null;
hideContinueWithoutUpgradingButton?: boolean;
};
@Component({
@@ -73,6 +77,8 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
protected step = signal<UnifiedUpgradeDialogStep>(UnifiedUpgradeDialogStep.PlanSelection);
protected selectedPlan = signal<PersonalSubscriptionPricingTierId | null>(null);
protected account = signal<Account | null>(null);
protected planSelectionStepTitleOverride = signal<string | null>(null);
protected hideContinueWithoutUpgradingButton = signal<boolean>(false);
protected readonly PaymentStep = UnifiedUpgradeDialogStep.Payment;
protected readonly PlanSelectionStep = UnifiedUpgradeDialogStep.PlanSelection;
@@ -86,6 +92,10 @@ export class UnifiedUpgradeDialogComponent implements OnInit {
this.account.set(this.params.account);
this.step.set(this.params.initialStep ?? UnifiedUpgradeDialogStep.PlanSelection);
this.selectedPlan.set(this.params.selectedPlan ?? null);
this.planSelectionStepTitleOverride.set(this.params.planSelectionStepTitleOverride ?? null);
this.hideContinueWithoutUpgradingButton.set(
this.params.hideContinueWithoutUpgradingButton ?? false,
);
}
protected onPlanSelected(planId: PersonalSubscriptionPricingTierId): void {

View File

@@ -1,6 +1,6 @@
@if (!loading()) {
<section
class="tw-bg-background tw-rounded-xl tw-shadow-lg tw-max-w-5xl tw-min-w-[332px] tw-w-[870px] tw-border-secondary-100 tw-border-solid tw-border"
class="tw-w-screen tw-max-h-screen tw-min-w-[332px] md:tw-max-w-4xl tw-overflow-y-auto tw-self-center tw-bg-background tw-rounded-xl tw-shadow-lg tw-border-secondary-100 tw-border-solid tw-border"
cdkTrapFocus
cdkTrapFocusAutoCapture
>
@@ -17,17 +17,17 @@
<div class="tw-px-14 tw-pb-8">
<div class="tw-flex tw-text-center tw-flex-col tw-pb-4">
<h1 class="tw-font-semibold tw-text-[32px]">
{{ "individualUpgradeWelcomeMessage" | i18n }}
{{ dialogTitle() | i18n }}
</h1>
<p bitTypography="body1" class="tw-text-muted">
{{ "individualUpgradeDescriptionMessage" | i18n }}
</p>
</div>
<div class="tw-flex tw-flex-row tw-gap-6 tw-mb-4">
<div class="tw-grid tw-grid-cols-1 sm:tw-grid-cols-2 tw-gap-5 tw-mb-4">
@if (premiumCardDetails) {
<billing-pricing-card
class="tw-flex-1 tw-basis-0 tw-min-w-0"
class="tw-w-full tw-min-w-[216px] tw-max-w-[456px]"
[tagline]="premiumCardDetails.tagline"
[price]="premiumCardDetails.price"
[button]="premiumCardDetails.button"
@@ -42,7 +42,7 @@
@if (familiesCardDetails) {
<billing-pricing-card
class="tw-flex-1 tw-basis-0 tw-min-w-0"
class="tw-w-full tw-min-w-[216px] tw-max-w-[456px]"
[tagline]="familiesCardDetails.tagline"
[price]="familiesCardDetails.price"
[button]="familiesCardDetails.button"
@@ -59,9 +59,11 @@
<p bitTypography="helper" class="tw-text-muted tw-italic">
{{ "individualUpgradeTaxInformationMessage" | i18n }}
</p>
<button bitLink linkType="primary" type="button" (click)="closeClicked.emit(closeStatus)">
{{ "continueWithoutUpgrading" | i18n }}
</button>
@if (!hideContinueWithoutUpgradingButton()) {
<button bitLink linkType="primary" type="button" (click)="closeClicked.emit(closeStatus)">
{{ "continueWithoutUpgrading" | i18n }}
</button>
}
</div>
</div>
</section>

View File

@@ -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();
});
});
});

View File

@@ -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<string | null>(null);
hideContinueWithoutUpgradingButton = input<boolean>(false);
planSelected = output<PersonalSubscriptionPricingTierId>();
closeClicked = output<UpgradeAccountStatus>();
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,

View File

@@ -0,0 +1,14 @@
<div class="tw-px-2 tw-mt-3 tw-mb-2 tw-h-10">
<div class="tw-rounded-full tw-bg-primary-100 tw-size-full">
<!-- Note that this is a custom button style for premium upgrade because the style desired
is not supported by the button in the CL. -->
<button
type="button"
class="tw-py-1.5 tw-px-4 tw-flex tw-gap-2 tw-items-center tw-size-full focus-visible:tw-ring-2 focus-visible:tw-ring-offset-0 focus:tw-outline-none focus-visible:tw-outline-none focus-visible:tw-ring-text-alt2 focus-visible:tw-z-10 tw-font-semibold tw-rounded-full tw-transition tw-border tw-border-solid tw-text-left tw-bg-primary-100 tw-text-primary-600 tw-border-primary-600 hover:tw-bg-hover-default hover:tw-text-primary-700 hover:tw-border-primary-700"
(click)="openUpgradeDialog()"
>
<i class="bwi bwi-premium" aria-hidden="true"></i>
{{ "upgradeYourPlan" | i18n }}
</button>
</div>
</div>

View File

@@ -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);
};
}

View File

@@ -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<UpgradeNavButtonComponent>;
export const Default: Story = {
render: (args) => ({
props: args,
template: `
<div class="tw-p-4 tw-bg-background-alt3">
<app-upgrade-nav-button></app-upgrade-nav-button>
</div>
`,
}),
};

View File

@@ -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<LogService>();
const mockApiService = mock<ApiService>();
const mockSyncService = mock<SyncService>();
const mockOrganizationService = mock<OrganizationService>();
const mockAccountService = mock<AccountService>();
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

View File

@@ -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<boolean> = 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<string> =
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
*/

View File

@@ -4,6 +4,22 @@
<ng-container bitDialogContent>
<section>
@if (isFamiliesPlan) {
@if (userIsOwnerOfFreeOrg$ | async) {
<div class="tw-pb-2">
<bit-callout type="info">
{{ "formWillCreateNewFamiliesOrgMessage" | i18n }}
<a
bitLink
bitDialogClose
linkType="primary"
[routerLink]="adminConsoleRouteForOwnedOrganization$ | async"
>
{{ "upgradeNow" | i18n }}
<i slot="end" class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-callout>
</div>
}
<div class="tw-pb-4">
<bit-form-field class="!tw-mb-0">
<bit-label>{{ "organizationName" | i18n }}</bit-label>
@@ -26,6 +42,7 @@
<section>
@if (passwordManager) {
<billing-cart-summary
#cartSummaryComponent
[passwordManager]="passwordManager"
[estimatedTax]="estimatedTax"
></billing-cart-summary>

View File

@@ -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<void> {
if (!this.isFamiliesPlan) {
this.formGroup.controls.organizationName.disable();
@@ -118,27 +122,24 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
tier: this.selectedPlanId(),
details: planDetails,
};
this.passwordManager = {
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
cost: this.selectedPlan.details.passwordManager.annualPrice,
quantity: 1,
cadence: "year",
};
this.upgradeToMessage = this.i18nService.t(
this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium",
);
this.estimatedTax = 0;
} else {
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
return;
}
});
if (!this.selectedPlan) {
this.complete.emit({ status: UpgradePaymentStatus.Closed, organizationId: null });
return;
}
this.passwordManager = {
name: this.isFamiliesPlan ? "familiesMembership" : "premiumMembership",
cost: this.selectedPlan.details.passwordManager.annualPrice,
quantity: 1,
cadence: "year",
};
this.upgradeToMessage = this.i18nService.t(
this.isFamiliesPlan ? "upgradeToFamilies" : "upgradeToPremium",
);
this.estimatedTax = 0;
this.formGroup.valueChanges
.pipe(debounceTime(1000), takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.refreshSalesTax());
@@ -146,7 +147,9 @@ export class UpgradePaymentComponent implements OnInit, AfterViewInit {
}
ngAfterViewInit(): void {
this.cartSummaryComponent.isExpanded.set(false);
if (this.cartSummaryComponent) {
this.cartSummaryComponent.isExpanded.set(false);
}
}
protected get isPremiumPlan(): boolean {

View File

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

View File

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

View File

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

View File

@@ -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<AccountKeysRequest> {
// 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;
}
}

View File

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

View File

@@ -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,
},
};
}

View File

@@ -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<V1UserCryptographicState> {
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<V2UserCryptographicState> {
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<UnlockDataRequest> {
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<UserDataRequest> {
return super.getAccountDataRequest(originalUserKey, newUnencryptedUserKey, user);
}
override makeNewUserKeyV1(oldUserKey: UserKey): Promise<UserKey> {
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<I18nService>;
let mockCryptoFunctionService: MockProxy<CryptoFunctionService>;
let mockKdfConfigService: MockProxy<KdfConfigService>;
let mockSdkClientFactory: MockProxy<SdkClientFactory>;
let mockSecurityStateService: MockProxy<SecurityStateService>;
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<UserKeyRotationApiService>();
mockCipherService = mock<CipherService>();
@@ -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<DialogService>();
mockCryptoFunctionService = mock<CryptoFunctionService>();
mockKdfConfigService = mock<KdfConfigService>();
mockSdkClientFactory = mock<SdkClientFactory>();
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<SecurityStateService>();
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<UserPrivateKey | null>;
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!",
);
});
});
});

View File

@@ -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<string | undefined> {
// 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<void> {
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<V1UserCryptographicState> {
// 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<V2UserCryptographicState> {
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<V2UserCryptographicState> {
// 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<V2UserCryptographicState> {
// 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<UnlockDataRequest> {
// 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<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.
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<T>(value: Observable<T>, name: string): Promise<T> {
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;
};

View File

@@ -1,19 +1,25 @@
<div class="tw-mt-auto">
<!-- [attr.icon] is used to keep the icon attribute on the bit-nav-item after prod mode is enabled. Matches other navigation items and assists in automated testing. -->
<bit-nav-item
*ngFor="let product of accessibleProducts$ | async"
[icon]="product.icon"
[text]="product.name"
[route]="product.appRoute"
[attr.icon]="product.icon"
[forceActiveStyles]="product.isActive"
>
</bit-nav-item>
<ng-container *ngIf="moreProducts$ | async as moreProducts">
<section
*ngIf="moreProducts.length > 0"
class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0"
@let accessibleProducts = accessibleProducts$ | async;
@if (accessibleProducts && accessibleProducts.length > 1) {
<!-- [attr.icon] is used to keep the icon attribute on the bit-nav-item after prod mode is enabled. Matches other navigation items and assists in automated testing. -->
<bit-nav-item
*ngFor="let product of accessibleProducts"
[icon]="product.icon"
[text]="product.name"
[route]="product.appRoute"
[attr.icon]="product.icon"
[forceActiveStyles]="product.isActive"
>
</bit-nav-item>
}
@if (shouldShowPremiumUpgradeButton$ | async) {
<app-upgrade-nav-button></app-upgrade-nav-button>
}
@let moreProducts = moreProducts$ | async;
@if (moreProducts && moreProducts.length > 0) {
<section class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0">
<span class="tw-text-xs !tw-text-alt2 tw-p-2 tw-pb-0">{{ "moreFromBitwarden" | i18n }}</span>
<ng-container *ngFor="let more of moreProducts">
<div class="tw-ps-2 tw-pe-2">
@@ -57,5 +63,5 @@
</div>
</ng-container>
</section>
</ng-container>
}
</div>

View File

@@ -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: "<div>Upgrade Nav Button</div>",
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<boolean>(false);
beforeEach(async () => {
productSwitcherService = mock<ProductSwitcherService>();
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();
});
});
});

View File

@@ -11,6 +11,9 @@ import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-s
export class NavigationProductSwitcherComponent {
constructor(private productSwitcherService: ProductSwitcherService) {}
protected readonly shouldShowPremiumUpgradeButton$: Observable<boolean> =
this.productSwitcherService.shouldShowPremiumUpgradeButton$;
protected readonly accessibleProducts$: Observable<ProductSwitcherItem[]> =
this.productSwitcherService.products$.pipe(map((products) => products.bento ?? []));

View File

@@ -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<PlatformUtilsService> {
}
}
class MockBillingAccountProfileStateService implements Partial<BillingAccountProfileStateService> {
hasPremiumFromAnySource$(userId: UserId): Observable<boolean> {
return of(false);
}
}
class MockConfigService implements Partial<ConfigService> {
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag): Observable<FeatureFlagValueType<Flag>> {
return of(false);
}
}
@Component({
selector: "story-layout",
template: `<ng-content></ng-content>`,
@@ -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,

View File

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

View File

@@ -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<PlatformUtilsService> {
}
}
class MockBillingAccountProfileStateService implements Partial<BillingAccountProfileStateService> {
hasPremiumFromAnySource$(userId: UserId): Observable<boolean> {
return of(false);
}
}
class MockConfigService implements Partial<ConfigService> {
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag): Observable<FeatureFlagValueType<Flag>> {
return of(false);
}
}
@Component({
selector: "story-layout",
template: `<ng-content></ng-content>`,
@@ -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,
{

View File

@@ -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<ProviderService>;
let accountService: FakeAccountService;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
let configService: MockProxy<ConfigService>;
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<ProviderService>();
accountService = mockAccountServiceWith(userId);
platformUtilsService = mock<PlatformUtilsService>();
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
configService = mock<ConfigService>();
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,
);
});
});
});

View File

@@ -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<boolean> = 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[];

View File

@@ -2,7 +2,7 @@
<bit-container>
<p bitTypography="body1">{{ "preferencesDesc" | i18n }}</p>
<form [formGroup]="form" [bitSubmit]="submit" class="tw-w-1/2">
<form [formGroup]="form" [bitSubmit]="submit" class="tw-w-full tw-max-w-md">
<bit-callout type="info" *ngIf="vaultTimeoutPolicyCallout | async as policy">
<span *ngIf="policy.timeout && policy.action">
{{

View File

@@ -104,7 +104,7 @@
label="{{ 'options' | i18n }}"
></button>
<bit-menu #cipherOptions>
<ng-container *ngIf="isNotDeletedLoginCipher">
<ng-container *ngIf="isActiveLoginCipher">
<button bitMenuItem type="button" (click)="copy('username')" *ngIf="hasUsernameToCopy">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copyUsername" | i18n }}
@@ -151,6 +151,20 @@
<i class="bwi bwi-fw bwi-file-text" aria-hidden="true"></i>
{{ "eventLogs" | i18n }}
</button>
@if (showArchiveButton) {
<button bitMenuItem (click)="archive()" type="button">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
</button>
}
@if (showUnArchiveButton) {
<button bitMenuItem (click)="unarchive()" type="button">
<i class="bwi bwi-fw bwi-unarchive" aria-hidden="true"></i>
{{ "unArchive" | i18n }}
</button>
}
<button bitMenuItem (click)="restore()" type="button" *ngIf="isDeleted && canRestoreCipher">
<i class="bwi bwi-fw bwi-undo" aria-hidden="true"></i>
{{ "restore" | i18n }}

View File

@@ -48,6 +48,14 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
* uses new permission restore logic from PM-15493
*/
@Input() canRestoreCipher: boolean;
/**
* user has archive permissions
*/
@Input() userCanArchive: boolean;
/**
* Enforge Org Data Ownership Policy Status
*/
@Input() enforceOrgDataOwnershipPolicy: boolean;
@Output() onEvent = new EventEmitter<VaultItemEvent<C>>();
@@ -76,6 +84,20 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
}
}
protected get showArchiveButton() {
return (
this.userCanArchive &&
!CipherViewLikeUtils.isArchived(this.cipher) &&
!CipherViewLikeUtils.isDeleted(this.cipher) &&
!this.cipher.organizationId
);
}
// If item is archived always show unarchive button, even if user is not premium
protected get showUnArchiveButton() {
return CipherViewLikeUtils.isArchived(this.cipher);
}
protected get clickAction() {
if (this.decryptionFailure) {
return "showFailedToDecrypt";
@@ -100,7 +122,12 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
return CipherViewLikeUtils.hasAttachments(this.cipher);
}
// Do not show attachments button if:
// item is archived AND user is not premium user
protected get showAttachments() {
if (CipherViewLikeUtils.isArchived(this.cipher) && !this.userCanArchive) {
return false;
}
return this.canEditCipher || this.hasAttachments;
}
@@ -124,7 +151,11 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
return CipherViewLikeUtils.decryptionFailure(this.cipher);
}
// Do Not show Assign to Collections option if item is archived
protected get showAssignToCollections() {
if (CipherViewLikeUtils.isArchived(this.cipher)) {
return false;
}
return (
this.organizations?.length &&
this.canAssignCollections &&
@@ -132,7 +163,16 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
);
}
// Do NOT show clone option if:
// item is archived AND user is not premium user
// item is archived AND enforce org data ownership policy is on
protected get showClone() {
if (
CipherViewLikeUtils.isArchived(this.cipher) &&
(!this.userCanArchive || this.enforceOrgDataOwnershipPolicy)
) {
return false;
}
return this.cloneable && !CipherViewLikeUtils.isDeleted(this.cipher);
}
@@ -140,10 +180,11 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
return this.useEvents && this.cipher.organizationId;
}
protected get isNotDeletedLoginCipher() {
protected get isActiveLoginCipher() {
return (
CipherViewLikeUtils.getType(this.cipher) === this.CipherType.Login &&
!CipherViewLikeUtils.isDeleted(this.cipher)
!CipherViewLikeUtils.isDeleted(this.cipher) &&
!CipherViewLikeUtils.isArchived(this.cipher)
);
}
@@ -191,20 +232,20 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
protected get showCopyUsername(): boolean {
const usernameCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "username");
return this.isNotDeletedLoginCipher && usernameCopy;
return this.isActiveLoginCipher && usernameCopy;
}
protected get showCopyPassword(): boolean {
const passwordCopy = CipherViewLikeUtils.hasCopyableValue(this.cipher, "password");
return this.isNotDeletedLoginCipher && this.cipher.viewPassword && passwordCopy;
return this.isActiveLoginCipher && this.cipher.viewPassword && passwordCopy;
}
protected get showCopyTotp(): boolean {
return this.isNotDeletedLoginCipher && this.showTotpCopyButton;
return this.isActiveLoginCipher && this.showTotpCopyButton;
}
protected get showLaunchUri(): boolean {
return this.isNotDeletedLoginCipher && this.canLaunch;
return this.isActiveLoginCipher && this.canLaunch;
}
protected get isDeletedCanRestore(): boolean {
@@ -236,6 +277,14 @@ export class VaultCipherRowComponent<C extends CipherViewLike> implements OnInit
this.onEvent.emit({ type: "viewEvents", item: this.cipher });
}
protected archive() {
this.onEvent.emit({ type: "archive", items: [this.cipher] });
}
protected unarchive() {
this.onEvent.emit({ type: "unarchive", items: [this.cipher] });
}
protected restore() {
this.onEvent.emit({ type: "restore", items: [this.cipher] });
}

View File

@@ -20,4 +20,6 @@ export type VaultItemEvent<C extends CipherViewLike> =
| { type: "delete"; items: VaultItem<C>[] }
| { type: "copyField"; item: C; field: "username" | "password" | "totp" }
| { type: "moveToFolder"; items: C[] }
| { type: "assignToCollections"; items: C[] };
| { type: "assignToCollections"; items: C[] }
| { type: "archive"; items: C[] }
| { type: "unarchive"; items: C[] };

View File

@@ -83,6 +83,22 @@
<i class="bwi bwi-fw bwi-collection-shared" aria-hidden="true"></i>
{{ "assignToCollections" | i18n }}
</button>
<button *ngIf="bulkArchiveAllowed" type="button" bitMenuItem (click)="bulkArchive()">
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
{{ "archiveVerb" | i18n }}
</button>
<button
*ngIf="bulkUnarchiveAllowed"
type="button"
bitMenuItem
(click)="bulkUnarchive()"
>
<i class="bwi bwi-fw bwi-unarchive" aria-hidden="true"></i>
{{ "unArchive" | i18n }}
</button>
<button
*ngIf="canRestoreSelected$ | async"
type="button"
@@ -161,6 +177,8 @@
"
(checkedToggled)="selection.toggle(item)"
(onEvent)="event($event)"
[userCanArchive]="userCanArchive"
[enforceOrgDataOwnershipPolicy]="enforceOrgDataOwnershipPolicy"
></tr>
</ng-container>
</ng-template>

View File

@@ -0,0 +1,139 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { TestBed } from "@angular/core/testing";
import { of } from "rxjs";
import { CollectionView } from "@bitwarden/admin-console/common";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { MenuModule, TableModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { VaultItem } from "./vault-item";
import { VaultItemsComponent } from "./vault-items.component";
describe("VaultItemsComponent", () => {
let component: VaultItemsComponent<CipherViewLike>;
const cipher1: Partial<CipherView> = {
id: "cipher-1",
name: "Cipher 1",
organizationId: undefined,
};
const cipher2: Partial<CipherView> = {
id: "cipher-2",
name: "Cipher 2",
organizationId: undefined,
};
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [VaultItemsComponent],
imports: [ScrollingModule, TableModule, I18nPipe, MenuModule],
providers: [
{
provide: CipherAuthorizationService,
useValue: {
canDeleteCipher$: jest.fn(),
canRestoreCipher$: jest.fn(),
},
},
{
provide: RestrictedItemTypesService,
useValue: {
restricted$: of([]),
isCipherRestricted: jest.fn().mockReturnValue(false),
},
},
{
provide: I18nService,
useValue: {
t: (key: string) => key,
},
},
],
});
const fixture = TestBed.createComponent(VaultItemsComponent);
component = fixture.componentInstance;
});
describe("bulkUnarchiveAllowed", () => {
it("returns false when no items are selected", () => {
component["selection"].clear();
expect(component.bulkUnarchiveAllowed).toBe(false);
});
it("returns false when selecting collections only", () => {
const collection1 = { id: "col-1", name: "Collection 1" } as CollectionView;
const collection2 = { id: "col-2", name: "Collection 2" } as CollectionView;
const items: VaultItem<CipherView>[] = [
{ collection: collection1 },
{ collection: collection2 },
];
component["selection"].select(...items);
expect(component.bulkUnarchiveAllowed).toBe(false);
});
it("returns true when selecting archived ciphers without organization", () => {
const archivedCipher1 = {
...cipher1,
archivedDate: new Date("2024-01-01"),
};
const archivedCipher2 = {
...cipher2,
archivedDate: new Date("2024-01-02"),
};
const items: VaultItem<CipherView>[] = [
{ cipher: archivedCipher1 as CipherView },
{ cipher: archivedCipher2 as CipherView },
];
component["selection"].select(...items);
expect(component.bulkUnarchiveAllowed).toBe(true);
});
it("returns false when any selected cipher has an organizationId", () => {
const archivedCipher1: Partial<CipherView> = {
...cipher1,
archivedDate: new Date("2024-01-01"),
organizationId: undefined,
};
const archivedCipher2: Partial<CipherView> = {
...cipher2,
archivedDate: new Date("2024-01-02"),
organizationId: "org-1",
};
const items: VaultItem<CipherView>[] = [
{ cipher: archivedCipher1 as CipherView },
{ cipher: archivedCipher2 as CipherView },
];
component["selection"].select(...items);
expect(component.bulkUnarchiveAllowed).toBe(false);
});
it("returns false when any selected cipher is not archived", () => {
const items: VaultItem<CipherView>[] = [
{ cipher: cipher1 as CipherView },
{ cipher: cipher2 as CipherView },
];
component["selection"].select(...items);
expect(component.bulkUnarchiveAllowed).toBe(false);
});
});
});

View File

@@ -64,6 +64,8 @@ export class VaultItemsComponent<C extends CipherViewLike> {
@Input() addAccessStatus: number;
@Input() addAccessToggle: boolean;
@Input() activeCollection: CollectionView | undefined;
@Input() userCanArchive: boolean;
@Input() enforceOrgDataOwnershipPolicy: boolean;
private restrictedPolicies = toSignal(this.restrictedItemTypesService.restricted$);
@@ -191,6 +193,30 @@ export class VaultItemsComponent<C extends CipherViewLike> {
);
}
get bulkArchiveAllowed() {
if (this.selection.selected.length === 0 || !this.userCanArchive) {
return false;
}
return (
this.userCanArchive &&
!this.selection.selected.find(
(item) => item.cipher && (item.cipher.organizationId || item.cipher.archivedDate),
)
);
}
// Bulk Unarchive button should appear for Archive vault even if user does not have archive permissions
get bulkUnarchiveAllowed() {
if (this.selection.selected.length === 0) {
return false;
}
return !this.selection.selected.find(
(item) => !item.cipher?.archivedDate || item.cipher?.organizationId,
);
}
//@TODO: remove this function when removing the limitItemDeletion$ feature flag.
get showDelete(): boolean {
if (this.selection.selected.length === 0) {
@@ -221,7 +247,17 @@ export class VaultItemsComponent<C extends CipherViewLike> {
}
get bulkAssignToCollectionsAllowed() {
return this.showBulkAddToCollections && this.ciphers.length > 0;
return (
this.showBulkAddToCollections &&
this.ciphers.length > 0 &&
!this.anySelectedCiphersAreArchived
);
}
get anySelectedCiphersAreArchived() {
return this.selection.selected.some(
(item) => item.cipher && CipherViewLikeUtils.isArchived(item.cipher),
);
}
protected canEditCollection(collection: CollectionView): boolean {
@@ -270,6 +306,24 @@ export class VaultItemsComponent<C extends CipherViewLike> {
});
}
protected bulkArchive() {
this.event({
type: "archive",
items: this.selection.selected
.filter((item) => item.cipher !== undefined)
.map((item) => item.cipher),
});
}
protected bulkUnarchive() {
this.event({
type: "unarchive",
items: this.selection.selected
.filter((item) => item.cipher !== undefined)
.map((item) => item.cipher),
});
}
protected bulkRestore() {
this.event({
type: "restore",

View File

@@ -8,6 +8,7 @@ import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
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";
@@ -29,6 +30,7 @@ describe("VaultBannersComponent", () => {
let messageSubject: Subject<{ command: string }>;
const premiumBanner$ = new BehaviorSubject<boolean>(false);
const pendingAuthRequest$ = new BehaviorSubject<boolean>(false);
const PM24996_ImplementUpgradeFromFreeDialogFlag$ = new BehaviorSubject<boolean>(false);
const mockUserId = Utils.newGuid() as UserId;
const bannerService = mock<VaultBannersService>({
@@ -88,7 +90,14 @@ describe("VaultBannersComponent", () => {
},
{
provide: ConfigService,
useValue: mock<ConfigService>(),
useValue: mock<ConfigService>({
getFeatureFlag$: jest.fn((flag: FeatureFlag) => {
if (flag === FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog) {
return PM24996_ImplementUpgradeFromFreeDialogFlag$;
}
return new BehaviorSubject(false);
}),
}),
},
],
})
@@ -104,8 +113,14 @@ describe("VaultBannersComponent", () => {
});
describe("premiumBannerVisible$", () => {
it("shows premium banner", async () => {
beforeEach(() => {
// Reset feature flag to default (false) before each test
PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false);
});
it("shows premium banner when shouldShowPremiumBanner is true and feature flag is off", async () => {
premiumBanner$.next(true);
PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false);
fixture.detectChanges();
@@ -113,8 +128,29 @@ describe("VaultBannersComponent", () => {
expect(banner.componentInstance.bannerType()).toBe("premium");
});
it("dismisses premium banner", async () => {
it("hides premium banner when feature flag is enabled", async () => {
premiumBanner$.next(true);
PM24996_ImplementUpgradeFromFreeDialogFlag$.next(true);
fixture.detectChanges();
const banner = fixture.debugElement.query(By.directive(BannerComponent));
expect(banner).toBeNull();
});
it("dismisses premium banner when shouldShowPremiumBanner is false", async () => {
premiumBanner$.next(false);
PM24996_ImplementUpgradeFromFreeDialogFlag$.next(false);
fixture.detectChanges();
const banner = fixture.debugElement.query(By.directive(BannerComponent));
expect(banner).toBeNull();
});
it("hides premium banner when both shouldShowPremiumBanner is false and feature flag is enabled", async () => {
premiumBanner$.next(false);
PM24996_ImplementUpgradeFromFreeDialogFlag$.next(true);
fixture.detectChanges();

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