diff --git a/.eslintrc.json b/.eslintrc.json index 4fba3dd6128..d6c329a94dc 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -34,20 +34,14 @@ } }, "rules": { - "@typescript-eslint/no-explicit-any": "off", // TODO: This should be re-enabled - "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], "@typescript-eslint/explicit-member-accessibility": [ "error", - { - "accessibility": "no-public" - } - ], - "@typescript-eslint/no-this-alias": [ - "error", - { - "allowedNames": ["self"] - } + { "accessibility": "no-public" } ], + "@typescript-eslint/no-explicit-any": "off", // TODO: This should be re-enabled + "@typescript-eslint/no-misused-promises": ["error", { "checksVoidReturn": false }], + "@typescript-eslint/no-this-alias": ["error", { "allowedNames": ["self"] }], + "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], "no-console": "error", "import/no-unresolved": "off", // TODO: Look into turning off once each package is an actual package. "import/order": [ diff --git a/.github/workflows/brew-bump-cli.yml b/.github/workflows/brew-bump-cli.yml index 477c9ace582..d2e998eacb0 100644 --- a/.github/workflows/brew-bump-cli.yml +++ b/.github/workflows/brew-bump-cli.yml @@ -23,7 +23,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "brew-bump-workflow-pat" diff --git a/.github/workflows/brew-bump-desktop.yml b/.github/workflows/brew-bump-desktop.yml index 0a5c3947161..1856cc5fb82 100644 --- a/.github/workflows/brew-bump-desktop.yml +++ b/.github/workflows/brew-bump-desktop.yml @@ -23,7 +23,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "brew-bump-workflow-pat" diff --git a/.github/workflows/build-browser.yml b/.github/workflows/build-browser.yml index 5e64dc35875..57c2fdcef07 100644 --- a/.github/workflows/build-browser.yml +++ b/.github/workflows/build-browser.yml @@ -361,7 +361,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@37ffa14164a7308bc273829edfe75c97cd562375 + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "crowdin-api-token" @@ -423,7 +423,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets if: failure() - uses: bitwarden/gh-actions/get-keyvault-secrets@37ffa14164a7308bc273829edfe75c97cd562375 + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" diff --git a/.github/workflows/build-cli.yml b/.github/workflows/build-cli.yml index 4d19c7e7cce..9fa71cb24fe 100644 --- a/.github/workflows/build-cli.yml +++ b/.github/workflows/build-cli.yml @@ -404,7 +404,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets if: failure() - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 8dfb88163a6..6db1e59a3fa 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -277,7 +277,7 @@ jobs: node-gyp install $(node -v) - name: Install AST - uses: bitwarden/gh-actions/install-ast@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/install-ast@62d1bf7c3e31c458cc7236b1e69a475d235cd78f - name: Set up environmentF run: choco install checksum --no-progress @@ -302,7 +302,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "code-signing-vault-url, @@ -1190,7 +1190,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "crowdin-api-token" @@ -1269,7 +1269,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets if: failure() - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" diff --git a/.github/workflows/build-web.yml b/.github/workflows/build-web.yml index 72117bde1f8..f5458448465 100644 --- a/.github/workflows/build-web.yml +++ b/.github/workflows/build-web.yml @@ -188,7 +188,7 @@ jobs: - name: Retrieve github PAT secrets id: retrieve-secret-pat - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "github-pat-bitwarden-devops-bot-repo-scope" @@ -264,7 +264,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "crowdin-api-token" @@ -325,7 +325,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets if: failure() - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "devops-alerts-slack-webhook-url" diff --git a/.github/workflows/crowdin-pull.yml b/.github/workflows/crowdin-pull.yml index fed766e71d8..d14938cc468 100644 --- a/.github/workflows/crowdin-pull.yml +++ b/.github/workflows/crowdin-pull.yml @@ -32,13 +32,13 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "crowdin-api-token, github-gpg-private-key, github-gpg-private-key-passphrase" - name: Download translations - uses: bitwarden/gh-actions/crowdin@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/crowdin@62d1bf7c3e31c458cc7236b1e69a475d235cd78f env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_API_TOKEN: ${{ steps.retrieve-secrets.outputs.crowdin-api-token }} diff --git a/.github/workflows/deploy-eu-prod-web.yml b/.github/workflows/deploy-eu-prod-web.yml index f051207680e..523e0f44de7 100644 --- a/.github/workflows/deploy-eu-prod-web.yml +++ b/.github/workflows/deploy-eu-prod-web.yml @@ -24,13 +24,13 @@ jobs: - name: Retrieve Storage Account connection string id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: webvault-westeurope-prod secrets: "sa-bitwarden-web-vault-dev-key-temp" - name: Download latest cloud asset - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-web.yml path: apps/web diff --git a/.github/workflows/deploy-eu-qa-web.yml b/.github/workflows/deploy-eu-qa-web.yml index 34525eaa5f7..2a1e271f18b 100644 --- a/.github/workflows/deploy-eu-qa-web.yml +++ b/.github/workflows/deploy-eu-qa-web.yml @@ -24,13 +24,13 @@ jobs: - name: Retrieve Storage Account connection string id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: webvaulteu-westeurope-qa secrets: "sa-bitwarden-web-vault-dev-key-temp" - name: Download latest cloud asset - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-web.yml path: apps/web diff --git a/.github/workflows/deploy-non-prod-web.yml b/.github/workflows/deploy-non-prod-web.yml index f7411f5432d..78e0f12b7e9 100644 --- a/.github/workflows/deploy-non-prod-web.yml +++ b/.github/workflows/deploy-non-prod-web.yml @@ -67,7 +67,7 @@ jobs: uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - name: Download latest cloud asset - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-web.yml path: apps/web diff --git a/.github/workflows/release-browser.yml b/.github/workflows/release-browser.yml index 407f81deb60..5fd9441f14a 100644 --- a/.github/workflows/release-browser.yml +++ b/.github/workflows/release-browser.yml @@ -41,7 +41,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@58a2fdfbd3f1fc7e6727bc5dc51d159f4df07072 + uses: bitwarden/gh-actions/release-version-check@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: release-type: ${{ github.event.inputs.release_type }} project-type: ts @@ -103,7 +103,7 @@ jobs: - name: Download latest Release build artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-browser.yml workflow_conclusion: success @@ -116,7 +116,7 @@ jobs: - name: Dry Run - Download latest master build artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-browser.yml workflow_conclusion: success diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 9ff812bf305..7c21cafcfd1 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -57,7 +57,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/release-version-check@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: release-type: ${{ github.event.inputs.release_type }} project-type: ts @@ -78,7 +78,7 @@ jobs: - name: Download all Release artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-cli.yml path: apps/cli @@ -87,7 +87,7 @@ jobs: - name: Dry Run - Download all artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-cli.yml path: apps/cli @@ -150,7 +150,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "snapcraft-store-token" @@ -162,7 +162,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-cli.yml path: apps/cli @@ -172,7 +172,7 @@ jobs: - name: Dry Run - Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-cli.yml path: apps/cli @@ -206,7 +206,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "cli-choco-api-key" @@ -222,7 +222,7 @@ jobs: - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-cli.yml path: apps/cli/dist @@ -232,7 +232,7 @@ jobs: - name: Dry Run - Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-cli.yml path: apps/cli/dist @@ -265,14 +265,14 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "npm-api-key" - name: Download artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-cli.yml path: apps/cli/build @@ -282,7 +282,7 @@ jobs: - name: Dry Run - Download artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-cli.yml path: apps/cli/build diff --git a/.github/workflows/release-desktop-beta.yml b/.github/workflows/release-desktop-beta.yml index 335d705d2d9..ce09a7d80ad 100644 --- a/.github/workflows/release-desktop-beta.yml +++ b/.github/workflows/release-desktop-beta.yml @@ -47,7 +47,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/release-version-check@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: release-type: 'Initial Release' project-type: ts @@ -231,7 +231,7 @@ jobs: node-gyp install $(node -v) - name: Install AST - uses: bitwarden/gh-actions/install-ast@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/install-ast@62d1bf7c3e31c458cc7236b1e69a475d235cd78f - name: Set up environment run: choco install checksum --no-progress @@ -249,7 +249,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "code-signing-vault-url, @@ -932,7 +932,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "aws-electron-access-id, diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 3dbc08f9859..f60020c7327 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -67,7 +67,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/release-version-check@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: release-type: ${{ inputs.release_type }} project-type: ts @@ -110,7 +110,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "aws-electron-access-id, @@ -123,7 +123,7 @@ jobs: - name: Download all artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-desktop.yml workflow_conclusion: success @@ -132,7 +132,7 @@ jobs: - name: Dry Run - Download all artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-desktop.yml workflow_conclusion: success @@ -185,7 +185,7 @@ jobs: --endpoint-url https://${CF_ACCOUNT}.r2.cloudflarestorage.com - name: Get checksum files - uses: bitwarden/gh-actions/get-checksum@82cfceb235b308c2eb63923824e61d8350d280db + uses: bitwarden/gh-actions/get-checksum@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: packages_dir: "apps/desktop/artifacts" file_path: "apps/desktop/artifacts/sha256-checksums.txt" @@ -263,7 +263,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "snapcraft-store-token" @@ -279,7 +279,7 @@ jobs: - name: Download Snap artifact if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-desktop.yml workflow_conclusion: success @@ -289,7 +289,7 @@ jobs: - name: Dry Run - Download Snap artifact if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-desktop.yml workflow_conclusion: success @@ -329,7 +329,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "cli-choco-api-key" @@ -347,7 +347,7 @@ jobs: - name: Download choco artifact if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-desktop.yml workflow_conclusion: success @@ -357,7 +357,7 @@ jobs: - name: Dry Run - Download choco artifact if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-desktop.yml workflow_conclusion: success diff --git a/.github/workflows/release-web.yml b/.github/workflows/release-web.yml index 5b4b7195aea..46113b94b40 100644 --- a/.github/workflows/release-web.yml +++ b/.github/workflows/release-web.yml @@ -41,7 +41,7 @@ jobs: - name: Check Release Version id: version - uses: bitwarden/gh-actions/release-version-check@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/release-version-check@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: release-type: ${{ github.event.inputs.release_type }} project-type: ts @@ -130,7 +130,7 @@ jobs: - name: Retrieve bot secrets id: retrieve-bot-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: bitwarden-ci secrets: "github-pat-bitwarden-devops-bot-repo-scope" @@ -144,7 +144,7 @@ jobs: - name: Download latest cloud asset if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-web.yml path: assets @@ -154,7 +154,7 @@ jobs: - name: Dry Run - Download latest cloud asset if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-web.yml path: assets @@ -227,7 +227,7 @@ jobs: - name: Download latest build artifacts if: ${{ github.event.inputs.release_type != 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-web.yml path: apps/web/artifacts @@ -238,7 +238,7 @@ jobs: - name: Dry Run - Download latest build artifacts if: ${{ github.event.inputs.release_type == 'Dry Run' }} - uses: bitwarden/gh-actions/download-artifacts@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/download-artifacts@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: workflow: build-web.yml path: apps/web/artifacts diff --git a/.github/workflows/staged-rollout-desktop.yml b/.github/workflows/staged-rollout-desktop.yml index ce56cc205dd..b0c57e1a1be 100644 --- a/.github/workflows/staged-rollout-desktop.yml +++ b/.github/workflows/staged-rollout-desktop.yml @@ -26,7 +26,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "aws-electron-access-id, diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index c650e42ecf4..f50a6fe6cd5 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -54,7 +54,7 @@ jobs: - name: Retrieve secrets id: retrieve-secrets - uses: bitwarden/gh-actions/get-keyvault-secrets@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/get-keyvault-secrets@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: keyvault: "bitwarden-ci" secrets: "github-gpg-private-key, github-gpg-private-key-passphrase" @@ -125,14 +125,14 @@ jobs: - name: Bump Browser Version - Manifest if: ${{ inputs.bump_browser == true }} - uses: bitwarden/gh-actions/version-bump@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/version-bump@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: version: ${{ inputs.version_number }} file_path: "apps/browser/src/manifest.json" - name: Bump Browser Version - Manifest v3 if: ${{ inputs.bump_browser == true }} - uses: bitwarden/gh-actions/version-bump@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/version-bump@62d1bf7c3e31c458cc7236b1e69a475d235cd78f with: version: ${{ inputs.version_number }} file_path: "apps/browser/src/manifest.v3.json" diff --git a/.github/workflows/workflow-linter.yml b/.github/workflows/workflow-linter.yml index aef3077eb35..f38d228dda6 100644 --- a/.github/workflows/workflow-linter.yml +++ b/.github/workflows/workflow-linter.yml @@ -8,4 +8,4 @@ on: jobs: call-workflow: - uses: bitwarden/gh-actions/.github/workflows/workflow-linter.yml@67ab95d7a466bcefdedf3f93cbc10bcff436edfe + uses: bitwarden/gh-actions/.github/workflows/workflow-linter.yml@62d1bf7c3e31c458cc7236b1e69a475d235cd78f diff --git a/apps/browser/config/base.json b/apps/browser/config/base.json index 81b11cd38b7..348f00d1f36 100644 --- a/apps/browser/config/base.json +++ b/apps/browser/config/base.json @@ -1,6 +1,7 @@ { "dev_flags": {}, "flags": { - "showPasswordless": true + "showPasswordless": true, + "enableCipherKeyEncryption": false } } diff --git a/apps/browser/config/development.json b/apps/browser/config/development.json index 972812a9c59..eafd0ffd878 100644 --- a/apps/browser/config/development.json +++ b/apps/browser/config/development.json @@ -6,6 +6,7 @@ } }, "flags": { - "showPasswordless": true + "showPasswordless": true, + "enableCipherKeyEncryption": false } } diff --git a/apps/browser/config/production.json b/apps/browser/config/production.json index b04d1531a2f..f57c3d9bc38 100644 --- a/apps/browser/config/production.json +++ b/apps/browser/config/production.json @@ -1,3 +1,5 @@ { - "flags": {} + "flags": { + "enableCipherKeyEncryption": false + } } diff --git a/apps/browser/src/_locales/ar/messages.json b/apps/browser/src/_locales/ar/messages.json index d48c573d5b4..be4eaca46f7 100644 --- a/apps/browser/src/_locales/ar/messages.json +++ b/apps/browser/src/_locales/ar/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "الميزة غير متوفرة" }, - "updateKey": { - "message": "لا يمكنك استخدام هذه المِيزة حتى تحديث مفتاح التشفير الخاص بك." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "العضوية المميزة" diff --git a/apps/browser/src/_locales/az/messages.json b/apps/browser/src/_locales/az/messages.json index b83fb91390e..774e3fd64cd 100644 --- a/apps/browser/src/_locales/az/messages.json +++ b/apps/browser/src/_locales/az/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Özəllik əlçatmazdır" }, - "updateKey": { - "message": "Şifrələmə açarınızı güncəlləyənə qədər bu özəlliyi istifadə edə bilməzsiniz." + "encryptionKeyMigrationRequired": { + "message": "Şifrələmə açarının daşınması tələb olunur. Şifrələmə açarınızı güncəlləmək üçün zəhmət olmasa veb anbar üzərindən giriş edin." }, "premiumMembership": { "message": "Premium üzvlük" diff --git a/apps/browser/src/_locales/be/messages.json b/apps/browser/src/_locales/be/messages.json index ba72d949b90..2fc14ecb1fc 100644 --- a/apps/browser/src/_locales/be/messages.json +++ b/apps/browser/src/_locales/be/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Функцыя недаступна" }, - "updateKey": { - "message": "Вы не зможаце выкарыстоўваць гэту функцыю, пакуль не абнавіце свой ключ шыфравання." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Прэміяльны статус" diff --git a/apps/browser/src/_locales/bg/messages.json b/apps/browser/src/_locales/bg/messages.json index ceab700e46a..0bd4ba3b7f9 100644 --- a/apps/browser/src/_locales/bg/messages.json +++ b/apps/browser/src/_locales/bg/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Функцията е недостъпна" }, - "updateKey": { - "message": "Трябва да обновите шифриращия си ключ, за да използвате тази възможност." + "encryptionKeyMigrationRequired": { + "message": "Необходима е промяна на шифриращия ключ. Впишете се в трезора си по уеб, за да обновите своя шифриращ ключ." }, "premiumMembership": { "message": "Платен абонамент" diff --git a/apps/browser/src/_locales/bn/messages.json b/apps/browser/src/_locales/bn/messages.json index db189fbcf0b..f57c4687ea3 100644 --- a/apps/browser/src/_locales/bn/messages.json +++ b/apps/browser/src/_locales/bn/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "বৈশিষ্ট্য অনুপলব্ধ" }, - "updateKey": { - "message": "আপনি আপনার এনক্রিপশন কী হালনাগাদ না করা পর্যন্ত এই বৈশিষ্ট্যটি ব্যবহার করতে পারবেন না।" + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "প্রিমিয়াম সদস্য" diff --git a/apps/browser/src/_locales/bs/messages.json b/apps/browser/src/_locales/bs/messages.json index e10d4d29a8c..1c930eeafc7 100644 --- a/apps/browser/src/_locales/bs/messages.json +++ b/apps/browser/src/_locales/bs/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" diff --git a/apps/browser/src/_locales/ca/messages.json b/apps/browser/src/_locales/ca/messages.json index 6bd4d028b41..41f022a4362 100644 --- a/apps/browser/src/_locales/ca/messages.json +++ b/apps/browser/src/_locales/ca/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Característica no disponible" }, - "updateKey": { - "message": "No podeu utilitzar aquesta característica fins que actualitzeu la vostra clau de xifratge." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Subscripció Premium" diff --git a/apps/browser/src/_locales/cs/messages.json b/apps/browser/src/_locales/cs/messages.json index 10f97ce9311..27ddddb5124 100644 --- a/apps/browser/src/_locales/cs/messages.json +++ b/apps/browser/src/_locales/cs/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Funkce je nedostupná" }, - "updateKey": { - "message": "Dokud neaktualizujete svůj šifrovací klíč, nemůžete tuto funkci použít." + "encryptionKeyMigrationRequired": { + "message": "Vyžaduje se migrace šifrovacího klíče. Pro aktualizaci šifrovacího klíče se přihlaste přes webový trezor." }, "premiumMembership": { "message": "Prémiové členství" diff --git a/apps/browser/src/_locales/cy/messages.json b/apps/browser/src/_locales/cy/messages.json index 2ce58e170a2..c7817b42bce 100644 --- a/apps/browser/src/_locales/cy/messages.json +++ b/apps/browser/src/_locales/cy/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Aelodaeth uwch" diff --git a/apps/browser/src/_locales/da/messages.json b/apps/browser/src/_locales/da/messages.json index ab815e6f029..cce307701b2 100644 --- a/apps/browser/src/_locales/da/messages.json +++ b/apps/browser/src/_locales/da/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Funktion ikke tilgængelig" }, - "updateKey": { - "message": "Du kan ikke bruge denne funktion, før du opdaterer din krypteringsnøgle." + "encryptionKeyMigrationRequired": { + "message": "Krypteringsnøglemigrering nødvendig. Log ind gennem web-boksen for at opdatere krypteringsnøglen." }, "premiumMembership": { "message": "Premium-medlemskab" diff --git a/apps/browser/src/_locales/de/messages.json b/apps/browser/src/_locales/de/messages.json index 62db8c33582..1b3eb19ee8e 100644 --- a/apps/browser/src/_locales/de/messages.json +++ b/apps/browser/src/_locales/de/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Funktion nicht verfügbar" }, - "updateKey": { - "message": "Du kannst diese Funktion nicht nutzen, solange du deinen Verschlüsselungsschlüssel nicht aktualisiert hast." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium-Mitgliedschaft" diff --git a/apps/browser/src/_locales/el/messages.json b/apps/browser/src/_locales/el/messages.json index a292bf175c3..552f0b14850 100644 --- a/apps/browser/src/_locales/el/messages.json +++ b/apps/browser/src/_locales/el/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Μη διαθέσιμο χαρακτηριστικό" }, - "updateKey": { - "message": "Δεν μπορείτε να χρησιμοποιήσετε αυτήν τη λειτουργία μέχρι να ενημερώσετε το κλειδί κρυπτογράφησης." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Συνδρομή Premium" diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 507bdc63257..857ad72aac5 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -2433,5 +2433,20 @@ }, "passkeyNotCopiedAlert": { "message": "The passkey will not be copied to the cloned item. Do you want to continue cloning this item?" + }, + "aliasDomain": { + "message": "Alias domain" + }, + "passwordRepromptDisabledAutofillOnPageLoad": { + "message": "Items with master password re-prompt cannot be auto-filled on page load. Auto-fill on page load turned off.", + "description": "Toast message for describing that master password re-prompt cannot be auto-filled on page load." + }, + "autofillOnPageLoadSetToDefault": { + "message": "Auto-fill on page load set to use default setting.", + "description": "Toast message for informing the user that auto-fill on page load has been set to the default setting." + }, + "turnOffMasterPasswordPromptToEditField": { + "message": "Turn off master password re-prompt to edit this field", + "description": "Message appearing below the autofill on load message when master password reprompt is set for a vault item." } } diff --git a/apps/browser/src/_locales/en_GB/messages.json b/apps/browser/src/_locales/en_GB/messages.json index 73bbaedf9ae..6307472164d 100644 --- a/apps/browser/src/_locales/en_GB/messages.json +++ b/apps/browser/src/_locales/en_GB/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" diff --git a/apps/browser/src/_locales/en_IN/messages.json b/apps/browser/src/_locales/en_IN/messages.json index 2da8f08deb2..8bdedacc45c 100644 --- a/apps/browser/src/_locales/en_IN/messages.json +++ b/apps/browser/src/_locales/en_IN/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" diff --git a/apps/browser/src/_locales/es/messages.json b/apps/browser/src/_locales/es/messages.json index b428e097edd..5890a499518 100644 --- a/apps/browser/src/_locales/es/messages.json +++ b/apps/browser/src/_locales/es/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Característica no disponible" }, - "updateKey": { - "message": "No puedes usar esta característica hasta que actualices tu clave de cifrado." + "encryptionKeyMigrationRequired": { + "message": "Se requiere migración de la clave de cifrado. Por favor, inicie sesión a través de la caja fuerte para actualizar su clave de cifrado." }, "premiumMembership": { "message": "Membresía Premium" diff --git a/apps/browser/src/_locales/et/messages.json b/apps/browser/src/_locales/et/messages.json index a2f90818aba..c3d12a9b120 100644 --- a/apps/browser/src/_locales/et/messages.json +++ b/apps/browser/src/_locales/et/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Funktsioon pole saadaval" }, - "updateKey": { - "message": "Seda funktsiooni ei saa enne krüpteerimise võtme uuendamist kasutada." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium versioon" diff --git a/apps/browser/src/_locales/eu/messages.json b/apps/browser/src/_locales/eu/messages.json index ed4caa5d495..a57b243a923 100644 --- a/apps/browser/src/_locales/eu/messages.json +++ b/apps/browser/src/_locales/eu/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Ezaugarria ez dago erabilgarri" }, - "updateKey": { - "message": "Ezin duzu ezaugarri hau erabili zifratze-gakoa eguneratu arte." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium bazkidea" diff --git a/apps/browser/src/_locales/fa/messages.json b/apps/browser/src/_locales/fa/messages.json index 8570b9c1c33..a6c4f2652c0 100644 --- a/apps/browser/src/_locales/fa/messages.json +++ b/apps/browser/src/_locales/fa/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "ویژگی موجود نیست" }, - "updateKey": { - "message": "تا زمانی که کد رمزنگاری را به‌روز نکنید نمی‌توانید از این قابلیت استفاده کنید." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "عضویت پرمیوم" diff --git a/apps/browser/src/_locales/fi/messages.json b/apps/browser/src/_locales/fi/messages.json index 8ab4261cc03..81bf6c64322 100644 --- a/apps/browser/src/_locales/fi/messages.json +++ b/apps/browser/src/_locales/fi/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Ominaisuus ei ole käytettävissä" }, - "updateKey": { - "message": "Et voi käyttää tätä toimintoa ennen kuin päivität salausavaimesi." + "encryptionKeyMigrationRequired": { + "message": "Salausavaimen siirto vaaditaan. Päivitä salausavaimesi kirjautumalla verkkoholviin." }, "premiumMembership": { "message": "Premium-jäsenyys" diff --git a/apps/browser/src/_locales/fil/messages.json b/apps/browser/src/_locales/fil/messages.json index cbdd895d03b..df4726ac9cb 100644 --- a/apps/browser/src/_locales/fil/messages.json +++ b/apps/browser/src/_locales/fil/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Hindi magagamit ang tampok" }, - "updateKey": { - "message": "Hindi mo maari gamitin ang tampok na ito hanggang hindi mo iupdate ang iyong encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Pagiging miyembro ng premium" diff --git a/apps/browser/src/_locales/fr/messages.json b/apps/browser/src/_locales/fr/messages.json index 19a9adc5de4..d4b04dfa24f 100644 --- a/apps/browser/src/_locales/fr/messages.json +++ b/apps/browser/src/_locales/fr/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Fonctionnalité non disponible" }, - "updateKey": { - "message": "Vous ne pouvez pas utiliser cette fonctionnalité avant de mettre à jour votre clé de chiffrement." + "encryptionKeyMigrationRequired": { + "message": "Migration de la clé de chiffrement nécessaire. Veuillez vous connecter sur le coffre web pour mettre à jour votre clé de chiffrement." }, "premiumMembership": { "message": "Adhésion Premium" @@ -1606,10 +1606,10 @@ "message": "Le déverrouillage biométrique dans le navigateur n’est pas pris en charge sur cet appareil" }, "biometricsFailedTitle": { - "message": "Biometrics failed" + "message": "Le déverrouillage biométique a échoué\n" }, "biometricsFailedDesc": { - "message": "Biometrics cannot be completed, consider using a master password or logging out. If this persists, please contact Bitwarden support." + "message": "Impossible d'utiliser le déverrouillage biométrique, utilisez votre mot de passe principal ou déconnectez-vous. Si le problème persiste, veuillez contacter le support Bitwarden." }, "nativeMessaginPermissionErrorTitle": { "message": "Permission non accordée" @@ -1992,7 +1992,7 @@ "message": "Export du coffre personnel" }, "exportingIndividualVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", + "message": "Seuls les éléments individuels du coffre associés à $EMAIL$ seront exportés. Les éléments du coffre de l'organisation ne seront pas inclus. Seules les informations sur les éléments du coffre seront exportées et n'incluront pas les pièces jointes associées.", "placeholders": { "email": { "content": "$1", @@ -2153,7 +2153,7 @@ "message": "Une notification a été envoyée à votre appareil." }, "loginInitiated": { - "message": "Login initiated" + "message": "Connexion initiée" }, "exposedMasterPassword": { "message": "Mot de passe principal exposé" @@ -2240,28 +2240,28 @@ "message": "S'ouvre dans une nouvelle fenêtre" }, "deviceApprovalRequired": { - "message": "Device approval required. Select an approval option below:" + "message": "L'approbation de l'appareil est requise. Sélectionnez une option d'approbation ci-dessous :" }, "rememberThisDevice": { - "message": "Remember this device" + "message": "Se souvenir de cet appareil" }, "uncheckIfPublicDevice": { - "message": "Uncheck if using a public device" + "message": "Décocher si vous utilisez un appareil public" }, "approveFromYourOtherDevice": { - "message": "Approve from your other device" + "message": "Approuver sur votre autre appareil" }, "requestAdminApproval": { - "message": "Request admin approval" + "message": "Demander l'approbation de l'administrateur" }, "approveWithMasterPassword": { - "message": "Approve with master password" + "message": "Approuver avec le mot de passe principal" }, "ssoIdentifierRequired": { - "message": "Organization SSO identifier is required." + "message": "Identifiant SSO de l'organisation requis." }, "eu": { - "message": "EU", + "message": "UE", "description": "European Union" }, "usDomain": { @@ -2280,28 +2280,28 @@ "message": "Affichage" }, "accountSuccessfullyCreated": { - "message": "Account successfully created!" + "message": "Compte créé avec succès !" }, "adminApprovalRequested": { - "message": "Admin approval requested" + "message": "Approbation de l'administrateur demandée" }, "adminApprovalRequestSentToAdmins": { "message": "Demande transmise à votre administrateur." }, "youWillBeNotifiedOnceApproved": { - "message": "You will be notified once approved." + "message": "Vous serez notifié une fois approuvé." }, "troubleLoggingIn": { - "message": "Trouble logging in?" + "message": "Problème pour vous connecter ?" }, "loginApproved": { - "message": "Login approved" + "message": "Connexion approuvée" }, "userEmailMissing": { - "message": "User email missing" + "message": "Courriel de l'utilisateur manquant" }, "deviceTrusted": { - "message": "Device trusted" + "message": "Appareil de confiance" }, "inputRequired": { "message": "Saisie requise." @@ -2310,7 +2310,7 @@ "message": "requis" }, "search": { - "message": "Search" + "message": "Rechercher" }, "inputMinLength": { "message": "La saisie doit comporter au moins $COUNT$ caractères.", @@ -2340,7 +2340,7 @@ } }, "inputMinValue": { - "message": "Input value must be at least $MIN$.", + "message": "La valeur d'entrée doit être au moins de $MIN$.", "placeholders": { "min": { "content": "$1", @@ -2349,7 +2349,7 @@ } }, "inputMaxValue": { - "message": "Input value must not exceed $MAX$.", + "message": "La valeur d'entrée ne doit pas excéder $MAX$.", "placeholders": { "max": { "content": "$1", @@ -2368,7 +2368,7 @@ "message": "La saisie n'est pas une adresse e-mail." }, "fieldsNeedAttention": { - "message": "$COUNT$ field(s) above need your attention.", + "message": "$COUNT$ champ(s) ci-dessus nécessitent votre attention.", "placeholders": { "count": { "content": "$1", @@ -2377,22 +2377,22 @@ } }, "selectPlaceholder": { - "message": "-- Select --" + "message": "-- Sélectionner --" }, "multiSelectPlaceholder": { - "message": "-- Type to filter --" + "message": "-- Saisir pour filtrer --" }, "multiSelectLoading": { - "message": "Retrieving options..." + "message": "Récupération des options..." }, "multiSelectNotFound": { - "message": "No items found" + "message": "Aucun élément trouvé" }, "multiSelectClearAll": { - "message": "Clear all" + "message": "Effacer tout" }, "plusNMore": { - "message": "+ $QUANTITY$ more", + "message": "+ $QUANTITY$ de plus", "placeholders": { "quantity": { "content": "$1", @@ -2401,7 +2401,7 @@ } }, "submenu": { - "message": "Submenu" + "message": "Sous-menu" }, "toggleCollapse": { "message": "Toggle collapse", diff --git a/apps/browser/src/_locales/gl/messages.json b/apps/browser/src/_locales/gl/messages.json index 6e95df17b01..bf1eedd8261 100644 --- a/apps/browser/src/_locales/gl/messages.json +++ b/apps/browser/src/_locales/gl/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" diff --git a/apps/browser/src/_locales/he/messages.json b/apps/browser/src/_locales/he/messages.json index c3cd77bc639..48d88f82d40 100644 --- a/apps/browser/src/_locales/he/messages.json +++ b/apps/browser/src/_locales/he/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "יכולת זו לא זמינה" }, - "updateKey": { - "message": "לא ניתן להשתמש ביכולת זו עד שתעדכן את מפתח ההצפנה שלך." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "חשבון פרימיום" diff --git a/apps/browser/src/_locales/hi/messages.json b/apps/browser/src/_locales/hi/messages.json index 5fafd94ccf8..2619e217370 100644 --- a/apps/browser/src/_locales/hi/messages.json +++ b/apps/browser/src/_locales/hi/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature Unavailable" }, - "updateKey": { - "message": "जब तक आप अपनी एन्क्रिप्शन कुंजी को अपडेट नहीं करते, तब तक आप इस सुविधा का उपयोग नहीं कर सकते हैं।" + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium Membership" diff --git a/apps/browser/src/_locales/hr/messages.json b/apps/browser/src/_locales/hr/messages.json index 1e81fd2d757..9113a3b2687 100644 --- a/apps/browser/src/_locales/hr/messages.json +++ b/apps/browser/src/_locales/hr/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Značajka nije dostupna" }, - "updateKey": { - "message": "Ne možeš koristiti ovu značajku prije nego ažuriraš ključ za šifriranje." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium članstvo" diff --git a/apps/browser/src/_locales/hu/messages.json b/apps/browser/src/_locales/hu/messages.json index b18a03ee769..3b1c44dcfbb 100644 --- a/apps/browser/src/_locales/hu/messages.json +++ b/apps/browser/src/_locales/hu/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Ez a funkció nem érhető el." }, - "updateKey": { - "message": "Ez a funkció nem használható, amíg nem frissíted a titkosítási kulcsod." + "encryptionKeyMigrationRequired": { + "message": "Titkosítási kulcs migráció szükséges. Jelentkezzünk be a webes széfen keresztül a titkosítási kulcs frissítéséhez." }, "premiumMembership": { "message": "Prémium tagság" diff --git a/apps/browser/src/_locales/id/messages.json b/apps/browser/src/_locales/id/messages.json index e51078ece28..3891b1ddc37 100644 --- a/apps/browser/src/_locales/id/messages.json +++ b/apps/browser/src/_locales/id/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Fitur Tidak Tersedia" }, - "updateKey": { - "message": "Anda tidak dapat menggunakan fitur ini sampai Anda memperbarui kunci enkripsi Anda." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Keanggotaan Premium" diff --git a/apps/browser/src/_locales/it/messages.json b/apps/browser/src/_locales/it/messages.json index c68e7aea3d3..9af1f04b8e2 100644 --- a/apps/browser/src/_locales/it/messages.json +++ b/apps/browser/src/_locales/it/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Funzionalità non disponibile" }, - "updateKey": { - "message": "Non puoi usare questa funzionalità finché non aggiorni la tua chiave di criptografia." + "encryptionKeyMigrationRequired": { + "message": "Migrazione della chiave di criptografia obbligatoria. Accedi tramite la cassaforte web per aggiornare la tua chiave di criptografia." }, "premiumMembership": { "message": "Abbonamento Premium" diff --git a/apps/browser/src/_locales/ja/messages.json b/apps/browser/src/_locales/ja/messages.json index 746401d241c..e02207a0051 100644 --- a/apps/browser/src/_locales/ja/messages.json +++ b/apps/browser/src/_locales/ja/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "サービスが利用できません" }, - "updateKey": { - "message": "暗号キーを更新するまでこの機能は使用できません。" + "encryptionKeyMigrationRequired": { + "message": "暗号化キーの移行が必要です。暗号化キーを更新するには、ウェブ保管庫からログインしてください。" }, "premiumMembership": { "message": "プレミアム会員" diff --git a/apps/browser/src/_locales/ka/messages.json b/apps/browser/src/_locales/ka/messages.json index ea38b1f9a47..c6503b69df3 100644 --- a/apps/browser/src/_locales/ka/messages.json +++ b/apps/browser/src/_locales/ka/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" diff --git a/apps/browser/src/_locales/km/messages.json b/apps/browser/src/_locales/km/messages.json index 6e95df17b01..bf1eedd8261 100644 --- a/apps/browser/src/_locales/km/messages.json +++ b/apps/browser/src/_locales/km/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" diff --git a/apps/browser/src/_locales/kn/messages.json b/apps/browser/src/_locales/kn/messages.json index 7762aa74db5..c4f5aee4955 100644 --- a/apps/browser/src/_locales/kn/messages.json +++ b/apps/browser/src/_locales/kn/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "ವೈಶಿಷ್ಟ್ಯ ಲಭ್ಯವಿಲ್ಲ" }, - "updateKey": { - "message": "ನಿಮ್ಮ ಎನ್‌ಕ್ರಿಪ್ಶನ್ ಕೀಲಿಯನ್ನು ನವೀಕರಿಸುವವರೆಗೆ ನೀವು ಈ ವೈಶಿಷ್ಟ್ಯವನ್ನು ಬಳಸಲಾಗುವುದಿಲ್ಲ." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "ಪ್ರೀಮಿಯಂ ಸದಸ್ಯತ್ವ" diff --git a/apps/browser/src/_locales/ko/messages.json b/apps/browser/src/_locales/ko/messages.json index 995c5a932dd..795b47eca83 100644 --- a/apps/browser/src/_locales/ko/messages.json +++ b/apps/browser/src/_locales/ko/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "기능 사용할 수 없음" }, - "updateKey": { - "message": "이 기능을 사용하려면 암호화 키를 업데이트해야 합니다." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "프리미엄 멤버십" diff --git a/apps/browser/src/_locales/lt/messages.json b/apps/browser/src/_locales/lt/messages.json index f66d8f3f642..8daba606fa6 100644 --- a/apps/browser/src/_locales/lt/messages.json +++ b/apps/browser/src/_locales/lt/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Funkcija neprieinama" }, - "updateKey": { - "message": "Negali naudotis šia funkcija, kol neatnaujinsi šifravimo raktą." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium narystė" diff --git a/apps/browser/src/_locales/lv/messages.json b/apps/browser/src/_locales/lv/messages.json index b49cea5167e..f094022695c 100644 --- a/apps/browser/src/_locales/lv/messages.json +++ b/apps/browser/src/_locales/lv/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Iespēja nav pieejama" }, - "updateKey": { - "message": "Jūs nevarat izmantot šo funkciju līdz jūs atjaunojat savu šifrēšanas atslēgu." + "encryptionKeyMigrationRequired": { + "message": "Nepieciešama šifrēšanas atslēgas nomaiņa. Lūgums pieteikties tīmekļa glabātavā, lai atjauninātu savu šifrēšanas atslēgu." }, "premiumMembership": { "message": "Premium dalība" diff --git a/apps/browser/src/_locales/ml/messages.json b/apps/browser/src/_locales/ml/messages.json index 8a377bb32a6..94e71f2d8f1 100644 --- a/apps/browser/src/_locales/ml/messages.json +++ b/apps/browser/src/_locales/ml/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "സവിശേഷത ലഭ്യമല്ല" }, - "updateKey": { - "message": "നിങ്ങളുടെ എൻ‌ക്രിപ്ഷൻ കീ അപ്‌ഡേറ്റ് ചെയ്യുന്നതുവരെ നിങ്ങൾക്ക് ഈ സവിശേഷത ഉപയോഗിക്കാൻ കഴിയില്ല." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "പ്രീമിയം അംഗത്വം" diff --git a/apps/browser/src/_locales/mr/messages.json b/apps/browser/src/_locales/mr/messages.json index 9c83fe4e03c..00a2e755376 100644 --- a/apps/browser/src/_locales/mr/messages.json +++ b/apps/browser/src/_locales/mr/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" diff --git a/apps/browser/src/_locales/my/messages.json b/apps/browser/src/_locales/my/messages.json index 6e95df17b01..bf1eedd8261 100644 --- a/apps/browser/src/_locales/my/messages.json +++ b/apps/browser/src/_locales/my/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" diff --git a/apps/browser/src/_locales/nb/messages.json b/apps/browser/src/_locales/nb/messages.json index 98715f38327..a69e43ddbd6 100644 --- a/apps/browser/src/_locales/nb/messages.json +++ b/apps/browser/src/_locales/nb/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Egenskapen er utilgjengelig" }, - "updateKey": { - "message": "Du kan ikke bruke denne funksjonen før du oppdaterer krypteringsnøkkelen din." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium-medlemskap" diff --git a/apps/browser/src/_locales/ne/messages.json b/apps/browser/src/_locales/ne/messages.json index 6e95df17b01..bf1eedd8261 100644 --- a/apps/browser/src/_locales/ne/messages.json +++ b/apps/browser/src/_locales/ne/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" diff --git a/apps/browser/src/_locales/nl/messages.json b/apps/browser/src/_locales/nl/messages.json index 623b16daf7f..d6dda3290e7 100644 --- a/apps/browser/src/_locales/nl/messages.json +++ b/apps/browser/src/_locales/nl/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Functionaliteit niet beschikbaar" }, - "updateKey": { - "message": "Je kunt deze functie pas gebruiken als je je encryptiesleutel bijwerkt." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium-abonnement" diff --git a/apps/browser/src/_locales/nn/messages.json b/apps/browser/src/_locales/nn/messages.json index 6e95df17b01..bf1eedd8261 100644 --- a/apps/browser/src/_locales/nn/messages.json +++ b/apps/browser/src/_locales/nn/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" diff --git a/apps/browser/src/_locales/or/messages.json b/apps/browser/src/_locales/or/messages.json index 6e95df17b01..bf1eedd8261 100644 --- a/apps/browser/src/_locales/or/messages.json +++ b/apps/browser/src/_locales/or/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" diff --git a/apps/browser/src/_locales/pl/messages.json b/apps/browser/src/_locales/pl/messages.json index dcc01d2284d..296c76ddbd1 100644 --- a/apps/browser/src/_locales/pl/messages.json +++ b/apps/browser/src/_locales/pl/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Funkcja jest niedostępna" }, - "updateKey": { - "message": "Nie możesz używać tej funkcji, dopóki nie zaktualizujesz klucza szyfrowania." + "encryptionKeyMigrationRequired": { + "message": "Wymagana jest migracja klucza szyfrowania. Zaloguj się przez sejf internetowy, aby zaktualizować klucz szyfrowania." }, "premiumMembership": { "message": "Konto Premium" diff --git a/apps/browser/src/_locales/pt_BR/messages.json b/apps/browser/src/_locales/pt_BR/messages.json index f3a760a426f..89ba426c960 100644 --- a/apps/browser/src/_locales/pt_BR/messages.json +++ b/apps/browser/src/_locales/pt_BR/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Funcionalidade Indisponível" }, - "updateKey": { - "message": "Você não pode usar este recurso, até você atualizar sua chave de criptografia." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Assinatura Premium" diff --git a/apps/browser/src/_locales/pt_PT/messages.json b/apps/browser/src/_locales/pt_PT/messages.json index 5c80b8dfa12..496350bb995 100644 --- a/apps/browser/src/_locales/pt_PT/messages.json +++ b/apps/browser/src/_locales/pt_PT/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Funcionalidade indisponível" }, - "updateKey": { - "message": "Não pode utilizar esta funcionalidade até atualizar a sua chave de encriptação." + "encryptionKeyMigrationRequired": { + "message": "É necessária a migração da chave de encriptação. Inicie sessão através do cofre Web para atualizar a sua chave de encriptação." }, "premiumMembership": { "message": "Subscrição Premium" diff --git a/apps/browser/src/_locales/ro/messages.json b/apps/browser/src/_locales/ro/messages.json index 1ee069177ee..cf8acef767b 100644 --- a/apps/browser/src/_locales/ro/messages.json +++ b/apps/browser/src/_locales/ro/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Funcție indisponibilă" }, - "updateKey": { - "message": "Nu puteți utiliza această caracteristică înainte de a actualiza cheia de criptare." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Abonament Premium" diff --git a/apps/browser/src/_locales/ru/messages.json b/apps/browser/src/_locales/ru/messages.json index 1b49f85e7a2..5dfbe64abc8 100644 --- a/apps/browser/src/_locales/ru/messages.json +++ b/apps/browser/src/_locales/ru/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Функция недоступна" }, - "updateKey": { - "message": "Вы не можете использовать эту функцию, пока не обновите свой ключ шифрования." + "encryptionKeyMigrationRequired": { + "message": "Требуется миграция ключа шифрования. Чтобы обновить ключ шифрования, войдите через веб-хранилище." }, "premiumMembership": { "message": "Премиум" diff --git a/apps/browser/src/_locales/si/messages.json b/apps/browser/src/_locales/si/messages.json index d42711812c8..2c9a44b2afe 100644 --- a/apps/browser/src/_locales/si/messages.json +++ b/apps/browser/src/_locales/si/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "විශේෂාංගය ලබාගත නොහැක" }, - "updateKey": { - "message": "ඔබ ඔබේ සංකේතාංකන යතුර යාවත්කාලීන කරන තුරු ඔබට මෙම අංගය භාවිතා කළ නොහැක." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "වාරික සාමාජිකත්වය" diff --git a/apps/browser/src/_locales/sk/messages.json b/apps/browser/src/_locales/sk/messages.json index 267ec80df9e..405402ccac7 100644 --- a/apps/browser/src/_locales/sk/messages.json +++ b/apps/browser/src/_locales/sk/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Funkcia nie je k dispozícii" }, - "updateKey": { - "message": "Túto funkciu nemožno použiť, pokým neaktualizujete svoj šifrovací kľúč." + "encryptionKeyMigrationRequired": { + "message": "Vyžaduje sa migrácia šifrovacieho kľúča. Na aktualizáciu šifrovacieho kľúča sa prihláste cez webový trezor." }, "premiumMembership": { "message": "Prémiové členstvo" diff --git a/apps/browser/src/_locales/sl/messages.json b/apps/browser/src/_locales/sl/messages.json index 0e05815294d..ee581226b0c 100644 --- a/apps/browser/src/_locales/sl/messages.json +++ b/apps/browser/src/_locales/sl/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Funkcija ni na voljo." }, - "updateKey": { - "message": "To funkcijo lahko uporabite šele, ko posodobite svoj šifrirni ključ." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium članstvo" diff --git a/apps/browser/src/_locales/sr/messages.json b/apps/browser/src/_locales/sr/messages.json index be561aad7c3..a75cd39ebca 100644 --- a/apps/browser/src/_locales/sr/messages.json +++ b/apps/browser/src/_locales/sr/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Функција је недоступна" }, - "updateKey": { - "message": "Не можете да користите ову способност док не промените Ваш кључ за шифровање." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Премијум чланство" diff --git a/apps/browser/src/_locales/sv/messages.json b/apps/browser/src/_locales/sv/messages.json index fb44f17c835..7a4816dfa96 100644 --- a/apps/browser/src/_locales/sv/messages.json +++ b/apps/browser/src/_locales/sv/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Funktion ej tillgänglig" }, - "updateKey": { - "message": "Du kan inte använda denna funktion förrän du uppdaterar din krypteringsnyckel." + "encryptionKeyMigrationRequired": { + "message": "Migrering av krypteringsnyckel krävs. Logga in på webbvalvet för att uppdatera din krypteringsnyckel." }, "premiumMembership": { "message": "Premium-medlemskap" diff --git a/apps/browser/src/_locales/te/messages.json b/apps/browser/src/_locales/te/messages.json index 6e95df17b01..bf1eedd8261 100644 --- a/apps/browser/src/_locales/te/messages.json +++ b/apps/browser/src/_locales/te/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature unavailable" }, - "updateKey": { - "message": "You cannot use this feature until you update your encryption key." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium membership" diff --git a/apps/browser/src/_locales/th/messages.json b/apps/browser/src/_locales/th/messages.json index d09c59940d1..20e234e4f8f 100644 --- a/apps/browser/src/_locales/th/messages.json +++ b/apps/browser/src/_locales/th/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Feature Unavailable" }, - "updateKey": { - "message": "คุณไม่สามารถใช้คุณลักษณะนี้ได้จนกว่าคุณจะปรับปรุงคีย์การเข้ารหัสลับของคุณ" + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Premium Membership" diff --git a/apps/browser/src/_locales/tr/messages.json b/apps/browser/src/_locales/tr/messages.json index 71080dd999c..f40c94f1222 100644 --- a/apps/browser/src/_locales/tr/messages.json +++ b/apps/browser/src/_locales/tr/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Özellik kullanılamıyor" }, - "updateKey": { - "message": "Şifreleme anahtarınızı güncellemeden bu özelliği kullanamazsınız." + "encryptionKeyMigrationRequired": { + "message": "Şifreleme anahtarınızın güncellenmesi gerekiyor. Şifreleme anahtarınızı güncellemek için lütfen web kasasına giriş yapın." }, "premiumMembership": { "message": "Premium üyelik" diff --git a/apps/browser/src/_locales/uk/messages.json b/apps/browser/src/_locales/uk/messages.json index 2934e17e328..88a9dd902d2 100644 --- a/apps/browser/src/_locales/uk/messages.json +++ b/apps/browser/src/_locales/uk/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Функція недоступна" }, - "updateKey": { - "message": "Ви не можете використовувати цю функцію доки не оновите свій ключ шифрування." + "encryptionKeyMigrationRequired": { + "message": "Потрібно перенести ключ шифрування. Увійдіть у вебсховище та оновіть свій ключ шифрування." }, "premiumMembership": { "message": "Преміум статус" diff --git a/apps/browser/src/_locales/vi/messages.json b/apps/browser/src/_locales/vi/messages.json index 62f63995c7b..7f256311f16 100644 --- a/apps/browser/src/_locales/vi/messages.json +++ b/apps/browser/src/_locales/vi/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "Tính năng không có sẵn" }, - "updateKey": { - "message": "Bạn không thể sử dụng tính năng này cho đến khi bạn cập nhật khoá mã hóa." + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "Thành viên Cao Cấp" diff --git a/apps/browser/src/_locales/zh_CN/messages.json b/apps/browser/src/_locales/zh_CN/messages.json index 64df3ec8778..04c9d9484c6 100644 --- a/apps/browser/src/_locales/zh_CN/messages.json +++ b/apps/browser/src/_locales/zh_CN/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "功能不可用" }, - "updateKey": { - "message": "在您更新加密密钥前,您不能使用此功能。" + "encryptionKeyMigrationRequired": { + "message": "需要迁移加密密钥。请登录网页版密码库来更新您的加密密钥。" }, "premiumMembership": { "message": "高级会员" diff --git a/apps/browser/src/_locales/zh_TW/messages.json b/apps/browser/src/_locales/zh_TW/messages.json index c436fd63a29..3612d93edb3 100644 --- a/apps/browser/src/_locales/zh_TW/messages.json +++ b/apps/browser/src/_locales/zh_TW/messages.json @@ -771,8 +771,8 @@ "featureUnavailable": { "message": "功能不可用" }, - "updateKey": { - "message": "更新加密金鑰前不能使用此功能。" + "encryptionKeyMigrationRequired": { + "message": "Encryption key migration required. Please login through the web vault to update your encryption key." }, "premiumMembership": { "message": "進階會員" diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 3a1f06a3b6e..f95ca60bab1 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -464,7 +464,7 @@ export default class NotificationBackground { private async getDecryptedCipherById(cipherId: string) { const cipher = await this.cipherService.get(cipherId); if (cipher != null && cipher.type === CipherType.Login) { - return await cipher.decrypt(); + return await cipher.decrypt(await this.cipherService.getKeyForCipherKeyDecryption(cipher)); } return null; } diff --git a/apps/browser/src/autofill/models/autofill-form.ts b/apps/browser/src/autofill/models/autofill-form.ts index e23539bd303..3f06e28a912 100644 --- a/apps/browser/src/autofill/models/autofill-form.ts +++ b/apps/browser/src/autofill/models/autofill-form.ts @@ -2,6 +2,7 @@ * Represents an HTML form whose elements can be autofilled */ export default class AutofillForm { + [key: string]: any; /** * The unique identifier assigned to this field during collection of the page details */ diff --git a/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts index 7ff85a7e8e7..e4a409eb599 100644 --- a/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/abstractions/collect-autofill-content.service.ts @@ -1,8 +1,32 @@ +import AutofillField from "../../models/autofill-field"; +import AutofillForm from "../../models/autofill-form"; import AutofillPageDetails from "../../models/autofill-page-details"; +import { ElementWithOpId, FormFieldElement } from "../../types"; + +type AutofillFormElements = Map, AutofillForm>; + +type AutofillFieldElements = Map, AutofillField>; + +type UpdateAutofillDataAttributeParams = { + element: ElementWithOpId; + attributeName: string; + dataTarget?: AutofillForm | AutofillField; + dataTargetKey?: string; +}; interface CollectAutofillContentService { getPageDetails(): Promise; getAutofillFieldElementByOpid(opid: string): HTMLElement | null; + queryAllTreeWalkerNodes( + rootNode: Node, + filterCallback: CallableFunction, + isObservingShadowRoot?: boolean + ): Node[]; } -export { CollectAutofillContentService }; +export { + AutofillFormElements, + AutofillFieldElements, + UpdateAutofillDataAttributeParams, + CollectAutofillContentService, +}; diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index fcf4dffd4aa..81bf107650d 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -19,9 +19,9 @@ import AutofillScript from "../models/autofill-script"; import { AutoFillOptions, AutofillService as AutofillServiceInterface, - PageDetail, FormData, GenerateFillScriptOptions, + PageDetail, } from "./abstractions/autofill.service"; import { AutoFillConstants, @@ -205,6 +205,7 @@ export default class AutofillService implements AutofillServiceInterface { if ( options.cipher.type !== CipherType.Login || + // eslint-disable-next-line @typescript-eslint/no-misused-promises totpPromise || !options.cipher.login.totp || (!canAccessPremium && !options.cipher.organizationUseTotp) @@ -260,7 +261,7 @@ export default class AutofillService implements AutofillServiceInterface { } } - if (cipher == null) { + if (cipher == null || (cipher.reprompt === CipherRepromptType.Password && !fromCommand)) { return null; } diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts index 706ceb0fe0f..b21f530e572 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.spec.ts @@ -1,3 +1,7 @@ +import { mock } from "jest-mock-extended"; + +import AutofillField from "../models/autofill-field"; +import AutofillForm from "../models/autofill-form"; import { ElementWithOpId, FillableFormFieldElement, @@ -32,7 +36,128 @@ describe("CollectAutofillContentService", () => { }); describe("getPageDetails", () => { - it("returns an object containing information about the curren page as well as autofill data for the forms and fields of the page", async () => { + beforeEach(() => { + jest + .spyOn(collectAutofillContentService as any, "setupMutationObserver") + .mockImplementationOnce(() => { + collectAutofillContentService["mutationObserver"] = mock(); + }); + }); + + it("sets up the mutation observer the first time getPageDetails is called", async () => { + await collectAutofillContentService.getPageDetails(); + await collectAutofillContentService.getPageDetails(); + + expect(collectAutofillContentService["setupMutationObserver"]).toHaveBeenCalledTimes(1); + }); + + it("returns an object with empty forms and fields if no fields were found on a previous iteration", async () => { + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["noFieldsFound"] = true; + jest.spyOn(collectAutofillContentService as any, "getFormattedPageDetails"); + jest.spyOn(collectAutofillContentService as any, "queryAutofillFormAndFieldElements"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFormsData"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldsData"); + + await collectAutofillContentService.getPageDetails(); + + expect(collectAutofillContentService["getFormattedPageDetails"]).toHaveBeenCalledWith({}, []); + expect( + collectAutofillContentService["queryAutofillFormAndFieldElements"] + ).not.toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFormsData"]).not.toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFieldsData"]).not.toHaveBeenCalled(); + }); + + it("returns an object with cached form and field data values", async () => { + const formId = "validFormId"; + const formAction = "https://example.com/"; + const formMethod = "post"; + const formName = "validFormName"; + const usernameFieldId = "usernameField"; + const usernameFieldName = "username"; + const usernameFieldLabel = "User Name"; + const passwordFieldId = "passwordField"; + const passwordFieldName = "password"; + const passwordFieldLabel = "Password"; + document.body.innerHTML = ` +
+ + + + +
+ `; + const formElement = document.getElementById(formId) as ElementWithOpId; + const autofillForm: AutofillForm = { + opid: "__form__0", + htmlAction: formAction, + htmlName: formName, + htmlID: formId, + htmlMethod: formMethod, + }; + const fieldElement = document.getElementById( + usernameFieldId + ) as ElementWithOpId; + const autofillField: AutofillField = { + opid: "__0", + elementNumber: 0, + maxLength: 999, + viewable: true, + htmlID: usernameFieldId, + htmlName: usernameFieldName, + htmlClass: null, + tabindex: null, + title: "", + tagName: "input", + "label-tag": usernameFieldLabel, + "label-data": null, + "label-aria": null, + "label-top": null, + "label-right": passwordFieldLabel, + "label-left": usernameFieldLabel, + placeholder: "", + rel: null, + type: "text", + value: "", + checked: false, + autoCompleteType: "", + disabled: false, + readonly: false, + selectInfo: null, + form: "__form__0", + "aria-hidden": false, + "aria-disabled": false, + "aria-haspopup": false, + "data-stripe": null, + }; + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["autofillFormElements"] = new Map([ + [formElement, autofillForm], + ]); + collectAutofillContentService["autofillFieldElements"] = new Map([ + [fieldElement, autofillField], + ]); + jest.spyOn(collectAutofillContentService as any, "getFormattedPageDetails"); + jest.spyOn(collectAutofillContentService as any, "getFormattedAutofillFormsData"); + jest.spyOn(collectAutofillContentService as any, "getFormattedAutofillFieldsData"); + jest.spyOn(collectAutofillContentService as any, "queryAutofillFormAndFieldElements"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFormsData"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldsData"); + + await collectAutofillContentService.getPageDetails(); + + expect(collectAutofillContentService["getFormattedPageDetails"]).toHaveBeenCalled(); + expect(collectAutofillContentService["getFormattedAutofillFormsData"]).toHaveBeenCalled(); + expect(collectAutofillContentService["getFormattedAutofillFieldsData"]).toHaveBeenCalled(); + expect( + collectAutofillContentService["queryAutofillFormAndFieldElements"] + ).not.toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFormsData"]).not.toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFieldsData"]).not.toHaveBeenCalled(); + }); + + it("returns an object containing information about the current page as well as autofill data for the forms and fields of the page", async () => { const documentTitle = "Test Page"; const formId = "validFormId"; const formAction = "https://example.com/"; @@ -145,6 +270,19 @@ describe("CollectAutofillContentService", () => { collectedTimestamp: expect.any(Number), }); }); + + it("sets the noFieldsFond property to true if the page has no forms or fields", async function () { + document.body.innerHTML = ""; + collectAutofillContentService["noFieldsFound"] = false; + jest.spyOn(collectAutofillContentService as any, "buildAutofillFormsData"); + jest.spyOn(collectAutofillContentService as any, "buildAutofillFieldsData"); + + await collectAutofillContentService.getPageDetails(); + + expect(collectAutofillContentService["buildAutofillFormsData"]).toHaveBeenCalled(); + expect(collectAutofillContentService["buildAutofillFieldsData"]).toHaveBeenCalled(); + expect(collectAutofillContentService["noFieldsFound"]).toBe(true); + }); }); describe("getAutofillFieldElementByOpid", () => { @@ -213,6 +351,44 @@ describe("CollectAutofillContentService", () => { }); describe("buildAutofillFormsData", () => { + it("will not attempt to gather data from a cached form element", () => { + const documentTitle = "Test Page"; + const formId = "validFormId"; + const formAction = "https://example.com/"; + const formMethod = "post"; + const formName = "validFormName"; + document.title = documentTitle; + document.body.innerHTML = ` +
+ + + + +
+ + `; + const formElement = document.getElementById(formId) as ElementWithOpId; + const existingAutofillForm: AutofillForm = { + opid: "__form__0", + htmlAction: formAction, + htmlName: formName, + htmlID: formId, + htmlMethod: formMethod, + }; + collectAutofillContentService["autofillFormElements"] = new Map([ + [formElement, existingAutofillForm], + ]); + const formElements = Array.from(document.querySelectorAll("form")); + jest.spyOn(collectAutofillContentService as any, "getFormActionAttribute"); + + const autofillFormsData = collectAutofillContentService["buildAutofillFormsData"]( + formElements as Node[] + ); + + expect(collectAutofillContentService["getFormActionAttribute"]).not.toHaveBeenCalled(); + expect(autofillFormsData).toStrictEqual({ __form__0: existingAutofillForm }); + }); + it("returns an object of AutofillForm objects with the form id as a key", () => { const documentTitle = "Test Page"; const formId1 = "validFormId"; @@ -237,7 +413,9 @@ describe("CollectAutofillContentService", () => { `; - const autofillFormsData = collectAutofillContentService["buildAutofillFormsData"](); + const { formElements } = collectAutofillContentService["queryAutofillFormAndFieldElements"](); + const autofillFormsData = + collectAutofillContentService["buildAutofillFormsData"](formElements); expect(autofillFormsData).toStrictEqual({ __form__0: { @@ -266,10 +444,17 @@ describe("CollectAutofillContentService", () => { .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") .mockResolvedValue(true); - const autofillFieldsPromise = collectAutofillContentService["buildAutofillFieldsData"](); + const { formFieldElements } = + collectAutofillContentService["queryAutofillFormAndFieldElements"](); + const autofillFieldsPromise = collectAutofillContentService["buildAutofillFieldsData"]( + formFieldElements as FormFieldElement[] + ); const autofillFieldsData = await Promise.resolve(autofillFieldsPromise); - expect(collectAutofillContentService["getAutofillFieldElements"]).toHaveBeenCalledWith(50); + expect(collectAutofillContentService["getAutofillFieldElements"]).toHaveBeenCalledWith( + 100, + formFieldElements + ); expect(collectAutofillContentService["buildAutofillFieldItem"]).toHaveBeenCalledTimes(2); expect(autofillFieldsPromise).toBeInstanceOf(Promise); expect(autofillFieldsData).toStrictEqual([ @@ -372,9 +557,6 @@ describe("CollectAutofillContentService", () => { const formElements: FormFieldElement[] = collectAutofillContentService["getAutofillFieldElements"](); - expect(document.querySelectorAll).toHaveBeenCalledWith( - 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]):not([type="file"]):not([data-bwignore]), textarea:not([data-bwignore]), select:not([data-bwignore]), span[data-bwautofill]' - ); expect(collectAutofillContentService["getPropertyOrAttribute"]).not.toHaveBeenCalled(); expect(formElements).toEqual([ usernameInput, @@ -538,6 +720,105 @@ describe("CollectAutofillContentService", () => { }); describe("buildAutofillFieldItem", () => { + it("returns an existing autofill field item if it exists", async () => { + const index = 0; + const usernameField = { + labelText: "Username", + id: "username-id", + classes: "username input classes", + name: "username", + type: "text", + maxLength: 42, + tabIndex: 0, + title: "Username Input Title", + autocomplete: "username-autocomplete", + dataLabel: "username-data-label", + ariaLabel: "username-aria-label", + placeholder: "username-placeholder", + rel: "username-rel", + value: "username-value", + dataStripe: "data-stripe", + }; + document.body.innerHTML = ` +
+ + +
+ `; + document.body.innerHTML = ` +
+ + +
+ `; + const existingFieldData: AutofillField = { + elementNumber: index, + htmlClass: usernameField.classes, + htmlID: usernameField.id, + htmlName: usernameField.name, + maxLength: usernameField.maxLength, + opid: `__${index}`, + tabindex: String(usernameField.tabIndex), + tagName: "input", + title: usernameField.title, + viewable: true, + }; + const usernameInput = document.getElementById( + usernameField.id + ) as ElementWithOpId; + usernameInput.opid = "__0"; + collectAutofillContentService["autofillFieldElements"].set(usernameInput, existingFieldData); + jest.spyOn(collectAutofillContentService as any, "getAutofillFieldMaxLength"); + jest + .spyOn(collectAutofillContentService["domElementVisibilityService"], "isFormFieldViewable") + .mockResolvedValue(true); + jest.spyOn(collectAutofillContentService as any, "getPropertyOrAttribute"); + jest.spyOn(collectAutofillContentService as any, "getElementValue"); + + const autofillFieldItem = await collectAutofillContentService["buildAutofillFieldItem"]( + usernameInput, + 0 + ); + + expect(collectAutofillContentService["getAutofillFieldMaxLength"]).not.toHaveBeenCalled(); + expect( + collectAutofillContentService["domElementVisibilityService"].isFormFieldViewable + ).not.toHaveBeenCalled(); + expect(collectAutofillContentService["getPropertyOrAttribute"]).not.toHaveBeenCalled(); + expect(collectAutofillContentService["getElementValue"]).not.toHaveBeenCalled(); + expect(autofillFieldItem).toEqual(existingFieldData); + }); + it("returns the AutofillField base data values without the field labels or input values if the passed element is a span element", async () => { const index = 0; const spanElementId = "span-element"; @@ -958,6 +1239,20 @@ describe("CollectAutofillContentService", () => { expect(labels).toEqual(document.querySelectorAll("label[for='username']")); }); + + it("removes any new lines generated for the query selector", () => { + document.body.innerHTML = ` + + + `; + const element = document.querySelector("input") as FillableFormFieldElement; + + const labels = collectAutofillContentService["queryElementLabels"](element); + + expect(labels).toEqual(document.querySelectorAll("label[for='username-id']")); + }); }); describe("createLabelElementsTag", () => { @@ -1585,4 +1880,466 @@ describe("CollectAutofillContentService", () => { expect(selectWithoutOptionsOptions).toEqual({ options: [] }); }); }); + + describe("getShadowRoot", () => { + it("returns null if the passed node is not an HTMLElement instance", () => { + const textNode = document.createTextNode("Hello, world!"); + const shadowRoot = collectAutofillContentService["getShadowRoot"](textNode); + + expect(shadowRoot).toEqual(null); + }); + + it("returns a value provided by Chrome's openOrClosedShadowRoot API", () => { + // eslint-disable-next-line + // @ts-ignore + globalThis.chrome.dom = { + openOrClosedShadowRoot: jest.fn(), + }; + const element = document.createElement("div"); + collectAutofillContentService["getShadowRoot"](element); + + // eslint-disable-next-line + // @ts-ignore + expect(chrome.dom.openOrClosedShadowRoot).toBeCalled(); + }); + }); + + describe("buildTreeWalkerNodesQueryResults", () => { + it("will recursively call itself if a shadowDOM element is found and will observe the element for mutations", () => { + collectAutofillContentService["mutationObserver"] = mock({ + observe: jest.fn(), + }); + jest.spyOn(collectAutofillContentService as any, "buildTreeWalkerNodesQueryResults"); + const shadowRoot = document.createElement("div"); + jest + .spyOn(collectAutofillContentService as any, "getShadowRoot") + .mockReturnValueOnce(shadowRoot); + const callbackFilter = jest.fn(); + + collectAutofillContentService["buildTreeWalkerNodesQueryResults"]( + document.body, + [], + callbackFilter, + true + ); + + expect(collectAutofillContentService["buildTreeWalkerNodesQueryResults"]).toBeCalledTimes(2); + expect(collectAutofillContentService["mutationObserver"].observe).toBeCalled(); + }); + + it("will not observe the shadowDOM element if required to skip", () => { + collectAutofillContentService["mutationObserver"] = mock({ + observe: jest.fn(), + }); + const shadowRoot = document.createElement("div"); + jest + .spyOn(collectAutofillContentService as any, "getShadowRoot") + .mockReturnValueOnce(shadowRoot); + const callbackFilter = jest.fn(); + + collectAutofillContentService["buildTreeWalkerNodesQueryResults"]( + document.body, + [], + callbackFilter, + false + ); + + expect(collectAutofillContentService["mutationObserver"].observe).not.toBeCalled(); + }); + }); + + describe("setupMutationObserver", () => { + it("sets up a mutation observer and observes the document element", () => { + jest.spyOn(MutationObserver.prototype, "observe"); + + collectAutofillContentService["setupMutationObserver"](); + + expect(collectAutofillContentService["mutationObserver"]).toBeInstanceOf(MutationObserver); + expect(collectAutofillContentService["mutationObserver"].observe).toBeCalled(); + }); + }); + + describe("handleMutationObserverMutation", () => { + it("will set the domRecentlyMutated value to true and the noFieldsFound value to false if a form or field node has been added ", () => { + const form = document.createElement("form"); + document.body.appendChild(form); + const addedNodes = document.querySelectorAll("form"); + const removedNodes = document.querySelectorAll("li"); + + const mutationRecord: MutationRecord = { + type: "childList", + addedNodes: addedNodes, + attributeName: null, + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: removedNodes, + target: document.body, + }; + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["noFieldsFound"] = true; + collectAutofillContentService["currentLocationHref"] = window.location.href; + jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated"); + + collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); + + expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(true); + expect(collectAutofillContentService["noFieldsFound"]).toEqual(false); + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).toBeCalledWith( + removedNodes, + true + ); + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).toBeCalledWith( + addedNodes + ); + }); + + it("will handle updating the autofill element if any attribute mutations are encountered", () => { + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "value", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: document.body, + }; + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["noFieldsFound"] = true; + collectAutofillContentService["currentLocationHref"] = window.location.href; + jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated"); + jest.spyOn(collectAutofillContentService as any, "handleAutofillElementAttributeMutation"); + + collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); + + expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(false); + expect(collectAutofillContentService["noFieldsFound"]).toEqual(true); + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).not.toBeCalled(); + expect(collectAutofillContentService["handleAutofillElementAttributeMutation"]).toBeCalled(); + }); + + it("will handle window location mutations", () => { + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "value", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: document.body, + }; + collectAutofillContentService["currentLocationHref"] = "https://someotherurl.com"; + jest.spyOn(collectAutofillContentService as any, "handleWindowLocationMutation"); + jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated"); + jest.spyOn(collectAutofillContentService as any, "handleAutofillElementAttributeMutation"); + + collectAutofillContentService["handleMutationObserverMutation"]([mutationRecord]); + + expect(collectAutofillContentService["handleWindowLocationMutation"]).toBeCalled(); + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).not.toBeCalled(); + expect( + collectAutofillContentService["handleAutofillElementAttributeMutation"] + ).not.toBeCalled(); + }); + }); + + describe("deleteCachedAutofillElement", () => { + it("removes the autofill form element from the map of elements", () => { + const formElement = document.createElement("form") as ElementWithOpId; + const autofillForm: AutofillForm = { + opid: "1234", + htmlName: "formEl", + htmlID: "formEl-id", + htmlAction: "https://example.com", + htmlMethod: "POST", + }; + collectAutofillContentService["autofillFormElements"] = new Map([ + [formElement, autofillForm], + ]); + + collectAutofillContentService["deleteCachedAutofillElement"](formElement); + + expect(collectAutofillContentService["autofillFormElements"].size).toEqual(0); + }); + + it("removes the autofill field element form the map of elements", () => { + const fieldElement = document.createElement("input") as ElementWithOpId; + const autofillField: AutofillField = { + elementNumber: 0, + htmlClass: "", + tabindex: "", + title: "", + viewable: false, + opid: "1234", + htmlName: "username", + htmlID: "username-id", + htmlType: "text", + htmlAutocomplete: "username", + htmlAutofocus: false, + htmlDisabled: false, + htmlMaxLength: 999, + htmlReadonly: false, + htmlRequired: false, + htmlValue: "jsmith", + }; + collectAutofillContentService["autofillFieldElements"] = new Map([ + [fieldElement, autofillField], + ]); + + collectAutofillContentService["deleteCachedAutofillElement"](fieldElement); + + expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0); + }); + }); + + describe("handleWindowLocationMutation", () => { + it("will set the current location to the global location href, set the dom recently mutated flag and the no fields found flag, clear out the autofill form and field maps, and update the autofill elements after mutation", () => { + collectAutofillContentService["currentLocationHref"] = "https://example.com/login"; + collectAutofillContentService["domRecentlyMutated"] = false; + collectAutofillContentService["noFieldsFound"] = true; + jest.spyOn(collectAutofillContentService as any, "updateAutofillElementsAfterMutation"); + + collectAutofillContentService["handleWindowLocationMutation"](); + + expect(collectAutofillContentService["currentLocationHref"]).toEqual(window.location.href); + expect(collectAutofillContentService["domRecentlyMutated"]).toEqual(true); + expect(collectAutofillContentService["noFieldsFound"]).toEqual(false); + expect(collectAutofillContentService["updateAutofillElementsAfterMutation"]).toBeCalled(); + expect(collectAutofillContentService["autofillFormElements"].size).toEqual(0); + expect(collectAutofillContentService["autofillFieldElements"].size).toEqual(0); + }); + }); + + describe("handleAutofillElementAttributeMutation", () => { + it("returns early if the target node is not an HTMLElement instance", () => { + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "value", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: document.createTextNode("Hello, world!"), + }; + jest.spyOn(collectAutofillContentService as any, "isAutofillElementNodeMutated"); + + collectAutofillContentService["handleAutofillElementAttributeMutation"](mutationRecord); + + expect(collectAutofillContentService["isAutofillElementNodeMutated"]).not.toBeCalled(); + }); + + it("will update the autofill form element data if the target node can be found in the autofillFormElements map", () => { + const targetNode = document.createElement("form") as ElementWithOpId; + targetNode.setAttribute("name", "username"); + targetNode.setAttribute("value", "jsmith"); + const autofillForm: AutofillForm = { + opid: "1234", + htmlName: "formEl", + htmlID: "formEl-id", + htmlAction: "https://example.com", + htmlMethod: "POST", + }; + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "id", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: targetNode, + }; + collectAutofillContentService["autofillFormElements"] = new Map([[targetNode, autofillForm]]); + jest.spyOn(collectAutofillContentService as any, "updateAutofillFormElementData"); + + collectAutofillContentService["handleAutofillElementAttributeMutation"](mutationRecord); + + expect(collectAutofillContentService["updateAutofillFormElementData"]).toBeCalledWith( + mutationRecord.attributeName, + mutationRecord.target, + autofillForm + ); + }); + + it("will update the autofill field element data if the target node can be found in the autofillFieldElements map", () => { + const targetNode = document.createElement("input") as ElementWithOpId; + targetNode.setAttribute("name", "username"); + targetNode.setAttribute("value", "jsmith"); + const autofillField: AutofillField = { + elementNumber: 0, + htmlClass: "", + tabindex: "", + title: "", + viewable: false, + opid: "1234", + htmlName: "username", + htmlID: "username-id", + htmlType: "text", + htmlAutocomplete: "username", + htmlAutofocus: false, + htmlDisabled: false, + htmlMaxLength: 999, + htmlReadonly: false, + htmlRequired: false, + htmlValue: "jsmith", + }; + const mutationRecord: MutationRecord = { + type: "attributes", + addedNodes: null, + attributeName: "id", + attributeNamespace: null, + nextSibling: null, + oldValue: null, + previousSibling: null, + removedNodes: null, + target: targetNode, + }; + collectAutofillContentService["autofillFieldElements"] = new Map([ + [targetNode, autofillField], + ]); + jest.spyOn(collectAutofillContentService as any, "updateAutofillFieldElementData"); + + collectAutofillContentService["handleAutofillElementAttributeMutation"](mutationRecord); + + expect(collectAutofillContentService["updateAutofillFieldElementData"]).toBeCalledWith( + mutationRecord.attributeName, + mutationRecord.target, + autofillField + ); + }); + }); + + describe("updateAutofillFormElementData", () => { + const formElement = document.createElement("form") as ElementWithOpId; + const autofillForm: AutofillForm = { + opid: "1234", + htmlName: "formEl", + htmlID: "formEl-id", + htmlAction: "https://example.com", + htmlMethod: "POST", + }; + const updatedAttributes = ["action", "name", "id", "method"]; + + updatedAttributes.forEach((attribute) => { + it(`will update the ${attribute} value for the form element`, () => { + jest.spyOn(collectAutofillContentService["autofillFormElements"], "set"); + + collectAutofillContentService["updateAutofillFormElementData"]( + attribute, + formElement, + autofillForm + ); + + expect(collectAutofillContentService["autofillFormElements"].set).toBeCalledWith( + formElement, + autofillForm + ); + }); + }); + + it("will not update an attribute value if it is not present in the updateActions object", () => { + jest.spyOn(collectAutofillContentService["autofillFormElements"], "set"); + + collectAutofillContentService["updateAutofillFormElementData"]( + "aria-label", + formElement, + autofillForm + ); + + expect(collectAutofillContentService["autofillFormElements"].set).not.toBeCalled(); + }); + }); + + describe("updateAutofillFieldElementData", () => { + const fieldElement = document.createElement("input") as ElementWithOpId; + const autofillField: AutofillField = { + htmlClass: "value", + htmlID: "", + htmlName: "", + opid: "", + tabindex: "", + title: "", + viewable: false, + elementNumber: 0, + }; + const updatedAttributes = [ + "maxlength", + "name", + "id", + "type", + "autocomplete", + "class", + "tabindex", + "title", + "value", + "rel", + "tagname", + "checked", + "disabled", + "readonly", + "data-label", + "aria-label", + "aria-hidden", + "aria-disabled", + "aria-haspopup", + "data-stripe", + ]; + + updatedAttributes.forEach((attribute) => { + it(`will update the ${attribute} value for the field element`, async () => { + jest.spyOn(collectAutofillContentService["autofillFieldElements"], "set"); + + await collectAutofillContentService["updateAutofillFieldElementData"]( + attribute, + fieldElement, + autofillField + ); + + expect(collectAutofillContentService["autofillFieldElements"].set).toBeCalledWith( + fieldElement, + autofillField + ); + }); + }); + + it("will check the dom element's visibility if the `style` or `class` attribute has updated ", async () => { + jest.spyOn( + collectAutofillContentService["domElementVisibilityService"], + "isFormFieldViewable" + ); + const attributes = ["class", "style"]; + + for (const attribute of attributes) { + await collectAutofillContentService["updateAutofillFieldElementData"]( + attribute, + fieldElement, + autofillField + ); + + expect( + collectAutofillContentService["domElementVisibilityService"].isFormFieldViewable + ).toBeCalledWith(fieldElement); + } + }); + + it("will not update an attribute value if it is not present in the updateActions object", async () => { + jest.spyOn(collectAutofillContentService["autofillFieldElements"], "set"); + + await collectAutofillContentService["updateAutofillFieldElementData"]( + "random-attribute", + fieldElement, + autofillField + ); + + expect(collectAutofillContentService["autofillFieldElements"].set).not.toBeCalled(); + }); + }); }); diff --git a/apps/browser/src/autofill/services/collect-autofill-content.service.ts b/apps/browser/src/autofill/services/collect-autofill-content.service.ts index ec7658c9863..4780c294ab1 100644 --- a/apps/browser/src/autofill/services/collect-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/collect-autofill-content.service.ts @@ -8,34 +8,70 @@ import { FormElementWithAttribute, } from "../types"; -import { CollectAutofillContentService as CollectAutofillContentServiceInterface } from "./abstractions/collect-autofill-content.service"; +import { + UpdateAutofillDataAttributeParams, + AutofillFieldElements, + AutofillFormElements, + CollectAutofillContentService as CollectAutofillContentServiceInterface, +} from "./abstractions/collect-autofill-content.service"; import DomElementVisibilityService from "./dom-element-visibility.service"; class CollectAutofillContentService implements CollectAutofillContentServiceInterface { private readonly domElementVisibilityService: DomElementVisibilityService; + private noFieldsFound = false; + private domRecentlyMutated = true; + private autofillFormElements: AutofillFormElements = new Map(); + private autofillFieldElements: AutofillFieldElements = new Map(); + private currentLocationHref = ""; + private mutationObserver: MutationObserver; + private updateAutofillElementsAfterMutationTimeout: NodeJS.Timeout; + private readonly updateAfterMutationTimeoutDelay = 1000; constructor(domElementVisibilityService: DomElementVisibilityService) { this.domElementVisibilityService = domElementVisibilityService; } /** - * Builds the data for all the forms and fields - * that are found within the page DOM. + * Builds the data for all forms and fields found within the page DOM. + * Sets up a mutation observer to verify DOM changes and returns early + * with cached data if no changes are detected. * @returns {Promise} * @public */ async getPageDetails(): Promise { - const autofillFormsData: Record = this.buildAutofillFormsData(); - const autofillFieldsData: AutofillField[] = await this.buildAutofillFieldsData(); + if (!this.mutationObserver) { + this.setupMutationObserver(); + } - return { - title: document.title, - url: (document.defaultView || window).location.href, - documentUrl: document.location.href, - forms: autofillFormsData, - fields: autofillFieldsData, - collectedTimestamp: Date.now(), - }; + if (!this.domRecentlyMutated && this.noFieldsFound) { + return this.getFormattedPageDetails({}, []); + } + + if ( + !this.domRecentlyMutated && + this.autofillFormElements.size && + this.autofillFieldElements.size + ) { + return this.getFormattedPageDetails( + this.getFormattedAutofillFormsData(), + this.getFormattedAutofillFieldsData() + ); + } + + const { formElements, formFieldElements } = this.queryAutofillFormAndFieldElements(); + const autofillFormsData: Record = + this.buildAutofillFormsData(formElements); + const autofillFieldsData: AutofillField[] = await this.buildAutofillFieldsData( + formFieldElements as FormFieldElement[] + ); + this.sortAutofillFieldElementsMap(); + + if (!Object.values(autofillFormsData).length || !autofillFieldsData.length) { + this.noFieldsFound = true; + } + + this.domRecentlyMutated = false; + return this.getFormattedPageDetails(autofillFormsData, autofillFieldsData); } /** @@ -46,15 +82,18 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * @returns {FormFieldElement | null} */ getAutofillFieldElementByOpid(opid: string): FormFieldElement | null { - const fieldElements = this.getAutofillFieldElements(); - const fieldElementsWithOpid = fieldElements.filter( + const cachedFormFieldElements = Array.from(this.autofillFieldElements.keys()); + const formFieldElements = cachedFormFieldElements?.length + ? cachedFormFieldElements + : this.getAutofillFieldElements(); + const fieldElementsWithOpid = formFieldElements.filter( (fieldElement) => (fieldElement as ElementWithOpId).opid === opid ) as ElementWithOpId[]; if (!fieldElementsWithOpid.length) { const elementIndex = parseInt(opid.split("__")[1], 10); - return fieldElements[elementIndex] || null; + return formFieldElements[elementIndex] || null; } if (fieldElementsWithOpid.length > 1) { @@ -65,30 +104,120 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte return fieldElementsWithOpid[0]; } + /** + * Queries the DOM for all the nodes that match the given filter callback + * and returns a collection of nodes. + * @param {Node} rootNode + * @param {Function} filterCallback + * @param {boolean} isObservingShadowRoot + * @returns {Node[]} + */ + queryAllTreeWalkerNodes( + rootNode: Node, + filterCallback: CallableFunction, + isObservingShadowRoot = true + ): Node[] { + const treeWalkerQueryResults: Node[] = []; + + this.buildTreeWalkerNodesQueryResults( + rootNode, + treeWalkerQueryResults, + filterCallback, + isObservingShadowRoot + ); + + return treeWalkerQueryResults; + } + + /** + * Sorts the AutofillFieldElements map by the elementNumber property. + * @private + */ + private sortAutofillFieldElementsMap() { + if (!this.autofillFieldElements.size) { + return; + } + + this.autofillFieldElements = new Map( + [...this.autofillFieldElements].sort((a, b) => a[1].elementNumber - b[1].elementNumber) + ); + } + + /** + * Formats and returns the AutofillPageDetails object + * @param {Record} autofillFormsData + * @param {AutofillField[]} autofillFieldsData + * @returns {AutofillPageDetails} + * @private + */ + private getFormattedPageDetails( + autofillFormsData: Record, + autofillFieldsData: AutofillField[] + ): AutofillPageDetails { + return { + title: document.title, + url: (document.defaultView || window).location.href, + documentUrl: document.location.href, + forms: autofillFormsData, + fields: autofillFieldsData, + collectedTimestamp: Date.now(), + }; + } + /** * Queries the DOM for all the forms elements and * returns a collection of AutofillForm objects. * @returns {Record} * @private */ - private buildAutofillFormsData(): Record { - const autofillForms: Record = {}; - const documentFormElements = document.querySelectorAll("form"); - - documentFormElements.forEach((formElement: HTMLFormElement, index: number) => { + private buildAutofillFormsData(formElements: Node[]): Record { + for (let index = 0; index < formElements.length; index++) { + const formElement = formElements[index] as ElementWithOpId; formElement.opid = `__form__${index}`; - autofillForms[formElement.opid] = { + const existingAutofillForm = this.autofillFormElements.get(formElement); + if (existingAutofillForm) { + existingAutofillForm.opid = formElement.opid; + this.autofillFormElements.set(formElement, existingAutofillForm); + continue; + } + + this.autofillFormElements.set(formElement, { opid: formElement.opid, - htmlAction: new URL( - this.getPropertyOrAttribute(formElement, "action"), - window.location.href - ).href, + htmlAction: this.getFormActionAttribute(formElement), htmlName: this.getPropertyOrAttribute(formElement, "name"), htmlID: this.getPropertyOrAttribute(formElement, "id"), htmlMethod: this.getPropertyOrAttribute(formElement, "method"), - }; - }); + }); + } + + return this.getFormattedAutofillFormsData(); + } + + /** + * Returns the action attribute of the form element. If the action attribute + * is a relative path, it will be converted to an absolute path. + * @param {ElementWithOpId} element + * @returns {string} + * @private + */ + private getFormActionAttribute(element: ElementWithOpId): string { + return new URL(this.getPropertyOrAttribute(element, "action"), window.location.href).href; + } + + /** + * Iterates over all known form elements and returns an AutofillForm object + * containing a key value pair of the form element's opid and the form data. + * @returns {Record} + * @private + */ + private getFormattedAutofillFormsData(): Record { + const autofillForms: Record = {}; + const autofillFormElements = Array.from(this.autofillFormElements); + for (let index = 0; index < autofillFormElements.length; index++) { + const [formElement, autofillForm] = autofillFormElements[index]; + autofillForms[formElement.opid] = autofillForm; + } return autofillForms; } @@ -99,8 +228,10 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * @returns {Promise} * @private */ - private async buildAutofillFieldsData(): Promise { - const autofillFieldElements = this.getAutofillFieldElements(50); + private async buildAutofillFieldsData( + formFieldElements: FormFieldElement[] + ): Promise { + const autofillFieldElements = this.getAutofillFieldElements(100, formFieldElements); const autofillFieldDataPromises = autofillFieldElements.map(this.buildAutofillFieldItem); return Promise.all(autofillFieldDataPromises); @@ -111,18 +242,19 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * and returns a list limited to the given `fieldsLimit` number that * is ordered by priority. * @param {number} fieldsLimit - The maximum number of fields to return + * @param {FormFieldElement[]} previouslyFoundFormFieldElements - The list of all the field elements * @returns {FormFieldElement[]} * @private */ - private getAutofillFieldElements(fieldsLimit?: number): FormFieldElement[] { - const formFieldElements: FormFieldElement[] = [ - ...(document.querySelectorAll( - 'input:not([type="hidden"]):not([type="submit"]):not([type="reset"]):not([type="button"]):not([type="image"]):not([type="file"]):not([data-bwignore]), ' + - "textarea:not([data-bwignore]), " + - "select:not([data-bwignore]), " + - "span[data-bwautofill]" - ) as NodeListOf), - ]; + private getAutofillFieldElements( + fieldsLimit?: number, + previouslyFoundFormFieldElements?: FormFieldElement[] + ): FormFieldElement[] { + const formFieldElements = + previouslyFoundFormFieldElements || + (this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) => + this.isNodeFormFieldElement(node) + ) as FormFieldElement[]); if (!fieldsLimit || formFieldElements.length <= fieldsLimit) { return formFieldElements; @@ -168,6 +300,15 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte ): Promise => { element.opid = `__${index}`; + const existingAutofillField = this.autofillFieldElements.get(element); + if (existingAutofillField) { + existingAutofillField.opid = element.opid; + existingAutofillField.elementNumber = index; + this.autofillFieldElements.set(element, existingAutofillField); + + return existingAutofillField; + } + const autofillFieldBase = { opid: element.opid, elementNumber: index, @@ -178,19 +319,16 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte htmlClass: this.getPropertyOrAttribute(element, "class"), tabindex: this.getPropertyOrAttribute(element, "tabindex"), title: this.getPropertyOrAttribute(element, "title"), - tagName: this.getPropertyOrAttribute(element, "tagName")?.toLowerCase(), + tagName: this.getAttributeLowerCase(element, "tagName"), }; if (element instanceof HTMLSpanElement) { + this.autofillFieldElements.set(element, autofillFieldBase); return autofillFieldBase; } let autofillFieldLabels = {}; - const autoCompleteType = - this.getPropertyOrAttribute(element, "x-autocompletetype") || - this.getPropertyOrAttribute(element, "autocompletetype") || - this.getPropertyOrAttribute(element, "autocomplete"); - const elementType = this.getPropertyOrAttribute(element, "type")?.toLowerCase(); + const elementType = this.getAttributeLowerCase(element, "type"); if (elementType !== "hidden") { autofillFieldLabels = { "label-tag": this.createAutofillFieldLabelTag(element), @@ -203,26 +341,87 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte }; } - return { + const autofillField = { ...autofillFieldBase, ...autofillFieldLabels, rel: this.getPropertyOrAttribute(element, "rel"), type: elementType, value: this.getElementValue(element), - checked: Boolean(this.getPropertyOrAttribute(element, "checked")), - autoCompleteType: autoCompleteType !== "off" ? autoCompleteType : null, - disabled: Boolean(this.getPropertyOrAttribute(element, "disabled")), - readonly: Boolean(this.getPropertyOrAttribute(element, "readOnly")), + checked: this.getAttributeBoolean(element, "checked"), + autoCompleteType: this.getAutoCompleteAttribute(element), + disabled: this.getAttributeBoolean(element, "disabled"), + readonly: this.getAttributeBoolean(element, "readonly"), selectInfo: element instanceof HTMLSelectElement ? this.getSelectElementOptions(element) : null, form: element.form ? this.getPropertyOrAttribute(element.form, "opid") : null, - "aria-hidden": this.getPropertyOrAttribute(element, "aria-hidden") === "true", - "aria-disabled": this.getPropertyOrAttribute(element, "aria-disabled") === "true", - "aria-haspopup": this.getPropertyOrAttribute(element, "aria-haspopup") === "true", + "aria-hidden": this.getAttributeBoolean(element, "aria-hidden", true), + "aria-disabled": this.getAttributeBoolean(element, "aria-disabled", true), + "aria-haspopup": this.getAttributeBoolean(element, "aria-haspopup", true), "data-stripe": this.getPropertyOrAttribute(element, "data-stripe"), }; + + this.autofillFieldElements.set(element, autofillField); + return autofillField; }; + /** + * Identifies the autocomplete attribute associated with an element and returns + * the value of the attribute if it is not set to "off". + * @param {ElementWithOpId} element + * @returns {string} + * @private + */ + private getAutoCompleteAttribute(element: ElementWithOpId): string { + const autoCompleteType = + this.getPropertyOrAttribute(element, "x-autocompletetype") || + this.getPropertyOrAttribute(element, "autocompletetype") || + this.getPropertyOrAttribute(element, "autocomplete"); + return autoCompleteType !== "off" ? autoCompleteType : null; + } + + /** + * Returns a boolean representing the attribute value of an element. + * @param {ElementWithOpId} element + * @param {string} attributeName + * @param {boolean} checkString + * @returns {boolean} + * @private + */ + private getAttributeBoolean( + element: ElementWithOpId, + attributeName: string, + checkString = false + ): boolean { + if (checkString) { + return this.getPropertyOrAttribute(element, attributeName) === "true"; + } + + return Boolean(this.getPropertyOrAttribute(element, attributeName)); + } + + /** + * Returns the attribute of an element as a lowercase value. + * @param {ElementWithOpId} element + * @param {string} attributeName + * @returns {string} + * @private + */ + private getAttributeLowerCase( + element: ElementWithOpId, + attributeName: string + ): string { + return this.getPropertyOrAttribute(element, attributeName)?.toLowerCase(); + } + + /** + * Returns the value of an element's property or attribute. + * @returns {AutofillField[]} + * @private + */ + private getFormattedAutofillFieldsData(): AutofillField[] { + return Array.from(this.autofillFieldElements.values()); + } + /** * Creates a label tag used to autofill the element pulled from a label * associated with the element's id, name, parent element or from an @@ -235,13 +434,14 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte */ private createAutofillFieldLabelTag(element: FillableFormFieldElement): string { const labelElementsSet: Set = new Set(element.labels); - if (labelElementsSet.size) { return this.createLabelElementsTag(labelElementsSet); } const labelElements: NodeListOf | null = this.queryElementLabels(element); - labelElements?.forEach((labelElement) => labelElementsSet.add(labelElement)); + for (let labelIndex = 0; labelIndex < labelElements?.length; labelIndex++) { + labelElementsSet.add(labelElements[labelIndex]); + } let currentElement: HTMLElement | null = element; while (currentElement && currentElement !== document.documentElement) { @@ -286,7 +486,9 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte return null; } - return document.querySelectorAll(labelQuerySelectors); + return (element.getRootNode() as Document | ShadowRoot).querySelectorAll( + labelQuerySelectors.replace(/\n/g, "") + ); } /** @@ -297,7 +499,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * @private */ private createLabelElementsTag = (labelElementsSet: Set): string => { - return [...labelElementsSet] + return Array.from(labelElementsSet) .map((labelElement) => { const textContent: string | null = labelElement ? labelElement.textContent || labelElement.innerText @@ -561,7 +763,7 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte * @private */ private getSelectElementOptions(element: HTMLSelectElement): { options: (string | null)[][] } { - const options = [...element.options].map((option) => { + const options = Array.from(element.options).map((option) => { const optionText = option.text ? String(option.text) .toLowerCase() @@ -573,6 +775,425 @@ class CollectAutofillContentService implements CollectAutofillContentServiceInte return { options }; } + + /** + * Queries all potential form and field elements from the DOM and returns + * a collection of form and field elements. Leverages the TreeWalker API + * to deep query Shadow DOM elements. + * @returns {{formElements: Node[], formFieldElements: Node[]}} + * @private + */ + private queryAutofillFormAndFieldElements(): { + formElements: Node[]; + formFieldElements: Node[]; + } { + const formElements: Node[] = []; + const formFieldElements: Node[] = []; + this.queryAllTreeWalkerNodes(document.documentElement, (node: Node) => { + if (node instanceof HTMLFormElement) { + formElements.push(node); + return true; + } + + if (this.isNodeFormFieldElement(node)) { + formFieldElements.push(node); + return true; + } + + return false; + }); + + return { formElements, formFieldElements }; + } + + /** + * Checks if the passed node is a form field element. + * @param {Node} node + * @returns {boolean} + * @private + */ + private isNodeFormFieldElement(node: Node): boolean { + const nodeIsSpanElementWithAutofillAttribute = + node instanceof HTMLSpanElement && node.hasAttribute("data-bwautofill"); + + const ignoredInputTypes = new Set(["hidden", "submit", "reset", "button", "image", "file"]); + const nodeIsValidInputElement = + node instanceof HTMLInputElement && !ignoredInputTypes.has(node.type); + + const nodeIsTextAreaOrSelectElement = + node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement; + + const nodeIsNonIgnoredFillableControlElement = + (nodeIsTextAreaOrSelectElement || nodeIsValidInputElement) && + !node.hasAttribute("data-bwignore"); + + return nodeIsSpanElementWithAutofillAttribute || nodeIsNonIgnoredFillableControlElement; + } + + /** + * Attempts to get the ShadowRoot of the passed node. If support for the + * extension based openOrClosedShadowRoot API is available, it will be used. + * @param {Node} node + * @returns {ShadowRoot | null} + * @private + */ + private getShadowRoot(node: Node): ShadowRoot | null { + if (!(node instanceof HTMLElement)) { + return null; + } + + if ((chrome as any).dom?.openOrClosedShadowRoot) { + return (chrome as any).dom.openOrClosedShadowRoot(node); + } + + return (node as any).openOrClosedShadowRoot || node.shadowRoot; + } + + /** + * Recursively builds a collection of nodes that match the given filter callback. + * If a node has a ShadowRoot, it will be observed for mutations. + * @param {Node} rootNode + * @param {Node[]} treeWalkerQueryResults + * @param {Function} filterCallback + * @param {boolean} isObservingShadowRoot + * @private + */ + private buildTreeWalkerNodesQueryResults( + rootNode: Node, + treeWalkerQueryResults: Node[], + filterCallback: CallableFunction, + isObservingShadowRoot: boolean + ) { + const treeWalker = document?.createTreeWalker(rootNode, NodeFilter.SHOW_ELEMENT); + let currentNode = treeWalker?.currentNode; + + while (currentNode) { + if (filterCallback(currentNode)) { + treeWalkerQueryResults.push(currentNode); + } + + const nodeShadowRoot = this.getShadowRoot(currentNode); + if (nodeShadowRoot) { + if (isObservingShadowRoot) { + this.mutationObserver.observe(nodeShadowRoot, { + attributes: true, + childList: true, + subtree: true, + }); + } + + this.buildTreeWalkerNodesQueryResults( + nodeShadowRoot, + treeWalkerQueryResults, + filterCallback, + isObservingShadowRoot + ); + } + + currentNode = treeWalker?.nextNode(); + } + } + + /** + * Sets up a mutation observer on the body of the document. Observes changes to + * DOM elements to ensure we have an updated set of autofill field data. + * @private + */ + private setupMutationObserver() { + this.currentLocationHref = globalThis.location.href; + this.mutationObserver = new MutationObserver(this.handleMutationObserverMutation); + this.mutationObserver.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true, + }); + } + + /** + * Handles observed DOM mutations and identifies if a mutation is related to + * an autofill element. If so, it will update the autofill element data. + * @param {MutationRecord[]} mutations + * @private + */ + private handleMutationObserverMutation = (mutations: MutationRecord[]) => { + if (this.currentLocationHref !== globalThis.location.href) { + this.handleWindowLocationMutation(); + + return; + } + + for (let mutationsIndex = 0; mutationsIndex < mutations.length; mutationsIndex++) { + const mutation = mutations[mutationsIndex]; + if ( + mutation.type === "childList" && + (this.isAutofillElementNodeMutated(mutation.removedNodes, true) || + this.isAutofillElementNodeMutated(mutation.addedNodes)) + ) { + this.domRecentlyMutated = true; + this.noFieldsFound = false; + continue; + } + + if (mutation.type === "attributes") { + this.handleAutofillElementAttributeMutation(mutation); + } + } + + if (this.domRecentlyMutated) { + this.updateAutofillElementsAfterMutation(); + } + }; + + /** + * Handles a mutation to the window location. Clears the autofill elements + * and updates the autofill elements after a timeout. + * @private + */ + private handleWindowLocationMutation() { + this.currentLocationHref = globalThis.location.href; + + this.domRecentlyMutated = true; + this.noFieldsFound = false; + + this.autofillFormElements.clear(); + this.autofillFieldElements.clear(); + + this.updateAutofillElementsAfterMutation(); + } + + /** + * Checks if the passed nodes either contain or are autofill elements. + * @param {NodeList} nodes + * @param {boolean} isRemovingNodes + * @returns {boolean} + * @private + */ + private isAutofillElementNodeMutated(nodes: NodeList, isRemovingNodes = false): boolean { + if (!nodes.length) { + return false; + } + + let isElementMutated = false; + const mutatedElements = []; + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index]; + if (!(node instanceof HTMLElement)) { + continue; + } + + if (node instanceof HTMLFormElement || this.isNodeFormFieldElement(node)) { + isElementMutated = true; + mutatedElements.push(node); + continue; + } + + const childNodes = this.queryAllTreeWalkerNodes( + node, + (node: Node) => node instanceof HTMLFormElement || this.isNodeFormFieldElement(node) + ) as HTMLElement[]; + if (childNodes.length) { + isElementMutated = true; + mutatedElements.push(...childNodes); + } + } + + if (isRemovingNodes) { + for (let elementIndex = 0; elementIndex < mutatedElements.length; elementIndex++) { + this.deleteCachedAutofillElement( + mutatedElements[elementIndex] as + | ElementWithOpId + | ElementWithOpId + ); + } + } + + return isElementMutated; + } + + /** + * Deletes any cached autofill elements that have been + * removed from the DOM. + * @param {ElementWithOpId | ElementWithOpId} element + * @private + */ + private deleteCachedAutofillElement( + element: ElementWithOpId | ElementWithOpId + ) { + if (element instanceof HTMLFormElement && this.autofillFormElements.has(element)) { + this.autofillFormElements.delete(element); + return; + } + + if (this.autofillFieldElements.has(element)) { + this.autofillFieldElements.delete(element); + } + } + + /** + * Updates the autofill elements after a DOM mutation has occurred. + * Is debounced to prevent excessive updates. + * @private + */ + private updateAutofillElementsAfterMutation() { + if (this.updateAutofillElementsAfterMutationTimeout) { + clearTimeout(this.updateAutofillElementsAfterMutationTimeout); + } + + this.updateAutofillElementsAfterMutationTimeout = setTimeout( + this.getPageDetails.bind(this), + this.updateAfterMutationTimeoutDelay + ); + } + + /** + * Handles observed DOM mutations related to an autofill element attribute. + * @param {MutationRecord} mutation + * @private + */ + private handleAutofillElementAttributeMutation(mutation: MutationRecord) { + const targetElement = mutation.target; + if (!(targetElement instanceof HTMLElement)) { + return; + } + + const attributeName = mutation.attributeName?.toLowerCase(); + const autofillForm = this.autofillFormElements.get( + targetElement as ElementWithOpId + ); + + if (autofillForm) { + this.updateAutofillFormElementData( + attributeName, + targetElement as ElementWithOpId, + autofillForm + ); + + return; + } + + const autofillField = this.autofillFieldElements.get( + targetElement as ElementWithOpId + ); + if (!autofillField) { + return; + } + + this.updateAutofillFieldElementData( + attributeName, + targetElement as ElementWithOpId, + autofillField + ); + } + + /** + * Updates the autofill form element data based on the passed attribute name. + * @param {string} attributeName + * @param {ElementWithOpId} element + * @param {AutofillForm} dataTarget + * @private + */ + private updateAutofillFormElementData( + attributeName: string, + element: ElementWithOpId, + dataTarget: AutofillForm + ) { + const updateAttribute = (dataTargetKey: string) => { + this.updateAutofillDataAttribute({ element, attributeName, dataTarget, dataTargetKey }); + }; + const updateActions: Record = { + action: () => (dataTarget.htmlAction = this.getFormActionAttribute(element)), + name: () => updateAttribute("htmlName"), + id: () => updateAttribute("htmlID"), + method: () => updateAttribute("htmlMethod"), + }; + + if (!updateActions[attributeName]) { + return; + } + + updateActions[attributeName](); + this.autofillFormElements.set(element, dataTarget); + } + + /** + * Updates the autofill field element data based on the passed attribute name. + * @param {string} attributeName + * @param {ElementWithOpId} element + * @param {AutofillField} dataTarget + * @returns {Promise} + * @private + */ + private async updateAutofillFieldElementData( + attributeName: string, + element: ElementWithOpId, + dataTarget: AutofillField + ) { + const updateAttribute = (dataTargetKey: string) => { + this.updateAutofillDataAttribute({ element, attributeName, dataTarget, dataTargetKey }); + }; + const updateActions: Record = { + maxlength: () => (dataTarget.maxLength = this.getAutofillFieldMaxLength(element)), + id: () => updateAttribute("htmlID"), + name: () => updateAttribute("htmlName"), + class: () => updateAttribute("htmlClass"), + tabindex: () => updateAttribute("tabindex"), + title: () => updateAttribute("tabindex"), + rel: () => updateAttribute("rel"), + tagname: () => (dataTarget.tagName = this.getAttributeLowerCase(element, "tagName")), + type: () => (dataTarget.type = this.getAttributeLowerCase(element, "type")), + value: () => (dataTarget.value = this.getElementValue(element)), + checked: () => (dataTarget.checked = this.getAttributeBoolean(element, "checked")), + disabled: () => (dataTarget.disabled = this.getAttributeBoolean(element, "disabled")), + readonly: () => (dataTarget.readonly = this.getAttributeBoolean(element, "readonly")), + autocomplete: () => (dataTarget.autoCompleteType = this.getAutoCompleteAttribute(element)), + "data-label": () => updateAttribute("label-data"), + "aria-label": () => updateAttribute("label-aria"), + "aria-hidden": () => + (dataTarget["aria-hidden"] = this.getAttributeBoolean(element, "aria-hidden", true)), + "aria-disabled": () => + (dataTarget["aria-disabled"] = this.getAttributeBoolean(element, "aria-disabled", true)), + "aria-haspopup": () => + (dataTarget["aria-haspopup"] = this.getAttributeBoolean(element, "aria-haspopup", true)), + "data-stripe": () => updateAttribute("data-stripe"), + }; + + if (!updateActions[attributeName]) { + return; + } + + updateActions[attributeName](); + + const visibilityAttributesSet = new Set(["class", "style"]); + if ( + visibilityAttributesSet.has(attributeName) && + !dataTarget.htmlClass?.includes("com-bitwarden-browser-animated-fill") + ) { + dataTarget.viewable = await this.domElementVisibilityService.isFormFieldViewable(element); + } + + this.autofillFieldElements.set(element, dataTarget); + } + + /** + * Gets the attribute value for the passed element, and returns it. If the dataTarget + * and dataTargetKey are passed, it will set the value of the dataTarget[dataTargetKey]. + * @param UpdateAutofillDataAttributeParams + * @returns {string} + * @private + */ + private updateAutofillDataAttribute({ + element, + attributeName, + dataTarget, + dataTargetKey, + }: UpdateAutofillDataAttributeParams) { + const attributeValue = this.getPropertyOrAttribute(element, attributeName); + if (dataTarget && dataTargetKey) { + dataTarget[dataTargetKey] = attributeValue; + } + + return attributeValue; + } } export default CollectAutofillContentService; diff --git a/apps/browser/src/autofill/services/dom-element-visibility.service.ts b/apps/browser/src/autofill/services/dom-element-visibility.service.ts index 4be59d7f276..2797ee0eb3d 100644 --- a/apps/browser/src/autofill/services/dom-element-visibility.service.ts +++ b/apps/browser/src/autofill/services/dom-element-visibility.service.ts @@ -13,7 +13,6 @@ class DomElementVisibilityService implements domElementVisibilityServiceInterfac */ async isFormFieldViewable(element: FormFieldElement): Promise { const elementBoundingClientRect = element.getBoundingClientRect(); - if ( this.isElementOutsideViewportBounds(element, elementBoundingClientRect) || this.isElementHiddenByCss(element) @@ -176,7 +175,10 @@ class DomElementVisibilityService implements domElementVisibilityServiceInterfac ): boolean { const elementBoundingClientRect = targetElementBoundingClientRect || targetElement.getBoundingClientRect(); - const elementAtCenterPoint = targetElement.ownerDocument.elementFromPoint( + const elementRootNode = targetElement.getRootNode(); + const rootElement = + elementRootNode instanceof ShadowRoot ? elementRootNode : targetElement.ownerDocument; + const elementAtCenterPoint = rootElement.elementFromPoint( elementBoundingClientRect.left + elementBoundingClientRect.width / 2, elementBoundingClientRect.top + elementBoundingClientRect.height / 2 ); diff --git a/apps/browser/src/autofill/services/insert-autofill-content.service.ts b/apps/browser/src/autofill/services/insert-autofill-content.service.ts index 4e47e73704b..ad40b76fbcd 100644 --- a/apps/browser/src/autofill/services/insert-autofill-content.service.ts +++ b/apps/browser/src/autofill/services/insert-autofill-content.service.ts @@ -82,7 +82,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf if ( !savedUrls?.some((url) => url.startsWith(`https://${window.location.hostname}`)) || window.location.protocol !== "http:" || - !document.querySelectorAll("input[type=password]")?.length + !this.isPasswordFieldWithinDocument() ) { return false; } @@ -95,6 +95,22 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf return !confirm(confirmationWarning); } + /** + * Checks if there is a password field within the current document. Includes + * password fields that are present within the shadow DOM. + * @returns {boolean} + * @private + */ + private isPasswordFieldWithinDocument(): boolean { + return Boolean( + this.collectAutofillContentService.queryAllTreeWalkerNodes( + document.documentElement, + (node: Node) => node instanceof HTMLInputElement && node.type === "password", + false + )?.length + ); + } + /** * Checking if the autofill is occurring within an untrusted iframe. If the page is within an untrusted iframe, * the user is prompted to confirm that they want to autofill on the page. If the user cancels the autofill, diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 88b445b2bf6..01d58622e3a 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -333,23 +333,6 @@ export default class MainBackground { ); this.searchService = new SearchService(this.logService, this.i18nService); - this.cipherService = new CipherService( - this.cryptoService, - this.settingsService, - this.apiService, - this.i18nService, - this.searchService, - this.stateService, - this.encryptService, - this.cipherFileUploadService - ); - this.folderService = new BrowserFolderService( - this.cryptoService, - this.i18nService, - this.cipherService, - this.stateService - ); - this.folderApiService = new FolderApiService(this.folderService, this.apiService); this.collectionService = new CollectionService( this.cryptoService, this.i18nService, @@ -373,14 +356,6 @@ export default class MainBackground { this.cryptoFunctionService, logoutCallback ); - this.vaultFilterService = new VaultFilterService( - this.stateService, - this.organizationService, - this.folderService, - this.cipherService, - this.collectionService, - this.policyService - ); this.passwordStrengthService = new PasswordStrengthService(); @@ -447,6 +422,36 @@ export default class MainBackground { this.userVerificationApiService ); + this.configApiService = new ConfigApiService(this.apiService, this.authService); + + this.configService = new BrowserConfigService( + this.stateService, + this.configApiService, + this.authService, + this.environmentService, + this.logService, + true + ); + + this.cipherService = new CipherService( + this.cryptoService, + this.settingsService, + this.apiService, + this.i18nService, + this.searchService, + this.stateService, + this.encryptService, + this.cipherFileUploadService, + this.configService + ); + this.folderService = new BrowserFolderService( + this.cryptoService, + this.i18nService, + this.cipherService, + this.stateService + ); + this.folderApiService = new FolderApiService(this.folderService, this.apiService); + this.vaultTimeoutSettingsService = new VaultTimeoutSettingsService( this.cryptoService, this.tokenService, @@ -455,6 +460,15 @@ export default class MainBackground { this.userVerificationService ); + this.vaultFilterService = new VaultFilterService( + this.stateService, + this.organizationService, + this.folderService, + this.cipherService, + this.collectionService, + this.policyService + ); + this.vaultTimeoutService = new VaultTimeoutService( this.cipherService, this.folderService, @@ -544,16 +558,6 @@ export default class MainBackground { this.messagingService ); - this.configApiService = new ConfigApiService(this.apiService, this.authService); - - this.configService = new BrowserConfigService( - this.stateService, - this.configApiService, - this.authService, - this.environmentService, - this.logService, - true - ); this.browserPopoutWindowService = new BrowserPopoutWindowService(); this.popupUtilsService = new PopupUtilsService(this.isPrivateMode); diff --git a/apps/browser/src/platform/background/service-factories/config-api.service.factory.ts b/apps/browser/src/platform/background/service-factories/config-api.service.factory.ts new file mode 100644 index 00000000000..e9e1b86488a --- /dev/null +++ b/apps/browser/src/platform/background/service-factories/config-api.service.factory.ts @@ -0,0 +1,32 @@ +import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; +import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; + +import { + authServiceFactory, + AuthServiceInitOptions, +} from "../../../auth/background/service-factories/auth-service.factory"; + +import { apiServiceFactory, ApiServiceInitOptions } from "./api-service.factory"; +import { FactoryOptions, CachedServices, factory } from "./factory-options"; + +type ConfigApiServiceFactoyOptions = FactoryOptions; + +export type ConfigApiServiceInitOptions = ConfigApiServiceFactoyOptions & + ApiServiceInitOptions & + AuthServiceInitOptions; + +export function configApiServiceFactory( + cache: { configApiService?: ConfigApiServiceAbstraction } & CachedServices, + opts: ConfigApiServiceInitOptions +): Promise { + return factory( + cache, + "configApiService", + opts, + async () => + new ConfigApiService( + await apiServiceFactory(cache, opts), + await authServiceFactory(cache, opts) + ) + ); +} diff --git a/apps/browser/src/platform/background/service-factories/config-service.factory.ts b/apps/browser/src/platform/background/service-factories/config-service.factory.ts new file mode 100644 index 00000000000..a5dc6016c65 --- /dev/null +++ b/apps/browser/src/platform/background/service-factories/config-service.factory.ts @@ -0,0 +1,49 @@ +import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction"; +import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; + +import { + authServiceFactory, + AuthServiceInitOptions, +} from "../../../auth/background/service-factories/auth-service.factory"; + +import { configApiServiceFactory, ConfigApiServiceInitOptions } from "./config-api.service.factory"; +import { + environmentServiceFactory, + EnvironmentServiceInitOptions, +} from "./environment-service.factory"; +import { FactoryOptions, CachedServices, factory } from "./factory-options"; +import { logServiceFactory, LogServiceInitOptions } from "./log-service.factory"; +import { stateServiceFactory, StateServiceInitOptions } from "./state-service.factory"; + +type ConfigServiceFactoryOptions = FactoryOptions & { + configServiceOptions?: { + subscribe?: boolean; + }; +}; + +export type ConfigServiceInitOptions = ConfigServiceFactoryOptions & + StateServiceInitOptions & + ConfigApiServiceInitOptions & + AuthServiceInitOptions & + EnvironmentServiceInitOptions & + LogServiceInitOptions; + +export function configServiceFactory( + cache: { configService?: ConfigServiceAbstraction } & CachedServices, + opts: ConfigServiceInitOptions +): Promise { + return factory( + cache, + "configService", + opts, + async () => + new ConfigService( + await stateServiceFactory(cache, opts), + await configApiServiceFactory(cache, opts), + await authServiceFactory(cache, opts), + await environmentServiceFactory(cache, opts), + await logServiceFactory(cache, opts), + opts.configServiceOptions?.subscribe ?? true + ) + ); +} diff --git a/apps/browser/src/popup/polyfills.ts b/apps/browser/src/popup/polyfills.ts index e41e960a8d4..14c0f595a39 100644 --- a/apps/browser/src/popup/polyfills.ts +++ b/apps/browser/src/popup/polyfills.ts @@ -1,5 +1,4 @@ import "core-js/stable"; -import "date-input-polyfill"; import "zone.js"; import "../platform/polyfills/zone-patch-chrome-runtime"; diff --git a/apps/browser/src/popup/scss/plugins.scss b/apps/browser/src/popup/scss/plugins.scss index 104927d73bb..e1e386d62d4 100644 --- a/apps/browser/src/popup/scss/plugins.scss +++ b/apps/browser/src/popup/scss/plugins.scss @@ -96,9 +96,3 @@ } } } - -date-input-polyfill { - &[data-open="true"] { - z-index: 10000 !important; - } -} diff --git a/apps/browser/src/tools/popup/generator/generator.component.html b/apps/browser/src/tools/popup/generator/generator.component.html index 13d35b6cd7a..cf7a2949f24 100644 --- a/apps/browser/src/tools/popup/generator/generator.component.html +++ b/apps/browser/src/tools/popup/generator/generator.component.html @@ -378,7 +378,7 @@ />
- +
+
+ + +
@@ -424,7 +434,7 @@ />
- +
+
diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts index 6148e31dd6d..54cb08ae893 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts @@ -264,4 +264,27 @@ export class AddEditComponent extends BaseAddEditComponent { } }, 200); } + + repromptChanged() { + super.repromptChanged(); + + if (!this.showAutoFillOnPageLoadOptions) { + return; + } + + if (this.reprompt) { + this.platformUtilsService.showToast( + "info", + null, + this.i18nService.t("passwordRepromptDisabledAutofillOnPageLoad") + ); + return; + } + + this.platformUtilsService.showToast( + "info", + null, + this.i18nService.t("autofillOnPageLoadSetToDefault") + ); + } } diff --git a/apps/cli/config/development.json b/apps/cli/config/development.json index b04d1531a2f..f57c3d9bc38 100644 --- a/apps/cli/config/development.json +++ b/apps/cli/config/development.json @@ -1,3 +1,5 @@ { - "flags": {} + "flags": { + "enableCipherKeyEncryption": false + } } diff --git a/apps/cli/config/production.json b/apps/cli/config/production.json index b04d1531a2f..f57c3d9bc38 100644 --- a/apps/cli/config/production.json +++ b/apps/cli/config/production.json @@ -1,3 +1,5 @@ { - "flags": {} + "flags": { + "enableCipherKeyEncryption": false + } } diff --git a/apps/cli/src/admin-console/commands/share.command.ts b/apps/cli/src/admin-console/commands/share.command.ts index 68bd8a18056..88fe256af18 100644 --- a/apps/cli/src/admin-console/commands/share.command.ts +++ b/apps/cli/src/admin-console/commands/share.command.ts @@ -45,11 +45,15 @@ export class ShareCommand { if (cipher.organizationId != null) { return Response.badRequest("This item already belongs to an organization."); } - const cipherView = await cipher.decrypt(); + const cipherView = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher) + ); try { await this.cipherService.shareWithServer(cipherView, organizationId, req); const updatedCipher = await this.cipherService.get(cipher.id); - const decCipher = await updatedCipher.decrypt(); + const decCipher = await updatedCipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher) + ); const res = new CipherResponse(decCipher); return Response.success(res); } catch (e) { diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 42ba158ee72..ca501690193 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -25,11 +25,13 @@ import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.ser import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service"; import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service"; import { ClientType, KeySuffixOptions, LogLevelType } from "@bitwarden/common/enums"; +import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { Account } from "@bitwarden/common/platform/models/domain/account"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; import { BroadcasterService } from "@bitwarden/common/platform/services/broadcaster.service"; +import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; import { ContainerService } from "@bitwarden/common/platform/services/container.service"; import { CryptoService } from "@bitwarden/common/platform/services/crypto.service"; import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation"; @@ -75,6 +77,7 @@ import { } from "@bitwarden/importer"; import { NodeCryptoFunctionService } from "@bitwarden/node/services/node-crypto-function.service"; +import { CliConfigService } from "./platform/services/cli-config.service"; import { CliPlatformUtilsService } from "./platform/services/cli-platform-utils.service"; import { ConsoleLogService } from "./platform/services/console-log.service"; import { I18nService } from "./platform/services/i18n.service"; @@ -147,6 +150,8 @@ export class Main { devicesApiService: DevicesApiServiceAbstraction; deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction; authRequestCryptoService: AuthRequestCryptoServiceAbstraction; + configApiService: ConfigApiServiceAbstraction; + configService: CliConfigService; constructor() { let p = null; @@ -252,28 +257,8 @@ export class Main { this.searchService = new SearchService(this.logService, this.i18nService); - this.cipherService = new CipherService( - this.cryptoService, - this.settingsService, - this.apiService, - this.i18nService, - this.searchService, - this.stateService, - this.encryptService, - this.cipherFileUploadService - ); - this.broadcasterService = new BroadcasterService(); - this.folderService = new FolderService( - this.cryptoService, - this.i18nService, - this.cipherService, - this.stateService - ); - - this.folderApiService = new FolderApiService(this.folderService, this.apiService); - this.collectionService = new CollectionService( this.cryptoService, this.i18nService, @@ -349,6 +334,38 @@ export class Main { this.authRequestCryptoService ); + this.configApiService = new ConfigApiService(this.apiService, this.authService); + + this.configService = new CliConfigService( + this.stateService, + this.configApiService, + this.authService, + this.environmentService, + this.logService, + true + ); + + this.cipherService = new CipherService( + this.cryptoService, + this.settingsService, + this.apiService, + this.i18nService, + this.searchService, + this.stateService, + this.encryptService, + this.cipherFileUploadService, + this.configService + ); + + this.folderService = new FolderService( + this.cryptoService, + this.i18nService, + this.cipherService, + this.stateService + ); + + this.folderApiService = new FolderApiService(this.folderService, this.apiService); + const lockedCallback = async (userId?: string) => await this.cryptoService.clearStoredUserKey(KeySuffixOptions.Auto); @@ -472,6 +489,7 @@ export class Main { const locale = await this.stateService.getLocale(); await this.i18nService.init(locale); this.twoFactorService.init(); + this.configService.init(); const installedVersion = await this.stateService.getInstalledVersion(); const currentVersion = await this.platformUtilsService.getApplicationVersion(); diff --git a/apps/cli/src/commands/edit.command.ts b/apps/cli/src/commands/edit.command.ts index 960b0999146..60e5ee7936e 100644 --- a/apps/cli/src/commands/edit.command.ts +++ b/apps/cli/src/commands/edit.command.ts @@ -77,7 +77,9 @@ export class EditCommand { return Response.notFound(); } - let cipherView = await cipher.decrypt(); + let cipherView = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher) + ); if (cipherView.isDeleted) { return Response.badRequest("You may not edit a deleted item. Use the restore command first."); } @@ -86,7 +88,9 @@ export class EditCommand { try { await this.cipherService.updateWithServer(encCipher); const updatedCipher = await this.cipherService.get(cipher.id); - const decCipher = await updatedCipher.decrypt(); + const decCipher = await updatedCipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher) + ); const res = new CipherResponse(decCipher); return Response.success(res); } catch (e) { @@ -109,7 +113,9 @@ export class EditCommand { try { await this.cipherService.saveCollectionsWithServer(cipher); const updatedCipher = await this.cipherService.get(cipher.id); - const decCipher = await updatedCipher.decrypt(); + const decCipher = await updatedCipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher) + ); const res = new CipherResponse(decCipher); return Response.success(res); } catch (e) { diff --git a/apps/cli/src/commands/get.command.ts b/apps/cli/src/commands/get.command.ts index 265e24c9d4e..4a84f1efefd 100644 --- a/apps/cli/src/commands/get.command.ts +++ b/apps/cli/src/commands/get.command.ts @@ -103,7 +103,9 @@ export class GetCommand extends DownloadCommand { if (Utils.isGuid(id)) { const cipher = await this.cipherService.get(id); if (cipher != null) { - decCipher = await cipher.decrypt(); + decCipher = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher) + ); } } else if (id.trim() !== "") { let ciphers = await this.cipherService.getAllDecrypted(); diff --git a/apps/cli/src/platform/services/cli-config.service.ts b/apps/cli/src/platform/services/cli-config.service.ts new file mode 100644 index 00000000000..6faa1b12e8a --- /dev/null +++ b/apps/cli/src/platform/services/cli-config.service.ts @@ -0,0 +1,9 @@ +import { NEVER } from "rxjs"; + +import { ConfigService } from "@bitwarden/common/platform/services/config/config.service"; + +export class CliConfigService extends ConfigService { + // The rxjs timer uses setTimeout/setInterval under the hood, which prevents the node process from exiting + // when the command is finished. Cli should never be alive long enough to use the timer, so we disable it. + protected refreshTimer$ = NEVER; +} diff --git a/apps/cli/src/vault/create.command.ts b/apps/cli/src/vault/create.command.ts index 3f4d53e9737..01217dbc307 100644 --- a/apps/cli/src/vault/create.command.ts +++ b/apps/cli/src/vault/create.command.ts @@ -80,7 +80,9 @@ export class CreateCommand { try { await this.cipherService.createWithServer(cipher); const newCipher = await this.cipherService.get(cipher.id); - const decCipher = await newCipher.decrypt(); + const decCipher = await newCipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(newCipher) + ); const res = new CipherResponse(decCipher); return Response.success(res); } catch (e) { @@ -141,7 +143,9 @@ export class CreateCommand { new Uint8Array(fileBuf).buffer ); const updatedCipher = await this.cipherService.get(cipher.id); - const decCipher = await updatedCipher.decrypt(); + const decCipher = await updatedCipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(updatedCipher) + ); return Response.success(new CipherResponse(decCipher)); } catch (e) { return Response.error(e); diff --git a/apps/desktop/config/base.json b/apps/desktop/config/base.json index 3ae895a19cf..cb408a87d87 100644 --- a/apps/desktop/config/base.json +++ b/apps/desktop/config/base.json @@ -1,6 +1,7 @@ { "dev_flags": {}, "flags": { - "multithreadDecryption": false + "multithreadDecryption": false, + "enableCipherKeyEncryption": false } } diff --git a/apps/desktop/config/development.json b/apps/desktop/config/development.json index b587e9ecfb9..d2b10738124 100644 --- a/apps/desktop/config/development.json +++ b/apps/desktop/config/development.json @@ -1,6 +1,7 @@ { "devFlags": {}, "flags": { - "showDDGSetting": true + "showDDGSetting": true, + "enableCipherKeyEncryption": false } } diff --git a/apps/desktop/config/production.json b/apps/desktop/config/production.json index 56f19341304..39b78094d0f 100644 --- a/apps/desktop/config/production.json +++ b/apps/desktop/config/production.json @@ -1,5 +1,6 @@ { "flags": { - "showDDGSetting": true + "showDDGSetting": true, + "enableCipherKeyEncryption": false } } diff --git a/apps/desktop/src/app/tools/generator.component.html b/apps/desktop/src/app/tools/generator.component.html index 93d82d4d949..1b2ee9df42e 100644 --- a/apps/desktop/src/app/tools/generator.component.html +++ b/apps/desktop/src/app/tools/generator.component.html @@ -405,7 +405,7 @@ />
- +
+
+ + +
@@ -451,7 +461,7 @@ />
- +
- +
+
+ + +
@@ -355,7 +366,7 @@ />
- + ((c as any).checked = false)); diff --git a/libs/angular/src/components/share.component.ts b/libs/angular/src/components/share.component.ts index 73792f91fef..6d0764be9b3 100644 --- a/libs/angular/src/components/share.component.ts +++ b/libs/angular/src/components/share.component.ts @@ -66,7 +66,9 @@ export class ShareComponent implements OnInit, OnDestroy { }); const cipherDomain = await this.cipherService.get(this.cipherId); - this.cipher = await cipherDomain.decrypt(); + this.cipher = await cipherDomain.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain) + ); this.filterCollections(); } @@ -94,7 +96,9 @@ export class ShareComponent implements OnInit, OnDestroy { } const cipherDomain = await this.cipherService.get(this.cipherId); - const cipherView = await cipherDomain.decrypt(); + const cipherView = await cipherDomain.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain) + ); const orgs = await firstValueFrom(this.organizations$); const orgName = orgs.find((o) => o.id === this.organizationId)?.name ?? this.i18nService.t("organization"); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 02f6278fec7..5c19d0fe771 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -271,7 +271,8 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; searchService: SearchServiceAbstraction, stateService: StateServiceAbstraction, encryptService: EncryptService, - fileUploadService: CipherFileUploadServiceAbstraction + fileUploadService: CipherFileUploadServiceAbstraction, + configService: ConfigServiceAbstraction ) => new CipherService( cryptoService, @@ -281,7 +282,8 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; searchService, stateService, encryptService, - fileUploadService + fileUploadService, + configService ), deps: [ CryptoServiceAbstraction, @@ -292,6 +294,7 @@ import { AbstractThemingService } from "./theming/theming.service.abstraction"; StateServiceAbstraction, EncryptService, CipherFileUploadServiceAbstraction, + ConfigServiceAbstraction, ], }, { diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 665071d462c..bcbfcc162bf 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -225,7 +225,9 @@ export class AddEditComponent implements OnInit, OnDestroy { if (this.cipher == null) { if (this.editMode) { const cipher = await this.loadCipher(); - this.cipher = await cipher.decrypt(); + this.cipher = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher) + ); // Adjust Cipher Name if Cloning if (this.cloneMode) { @@ -272,6 +274,9 @@ export class AddEditComponent implements OnInit, OnDestroy { } this.previousCipherId = this.cipherId; this.reprompt = this.cipher.reprompt !== CipherRepromptType.None; + if (this.reprompt) { + this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[2].value; + } } async submit(): Promise { @@ -574,8 +579,10 @@ export class AddEditComponent implements OnInit, OnDestroy { this.reprompt = !this.reprompt; if (this.reprompt) { this.cipher.reprompt = CipherRepromptType.Password; + this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[2].value; } else { this.cipher.reprompt = CipherRepromptType.None; + this.cipher.login.autofillOnPageLoad = this.autofillOnPageLoadOptions[0].value; } } diff --git a/libs/angular/src/vault/components/attachments.component.ts b/libs/angular/src/vault/components/attachments.component.ts index c7a8dd2ee27..e1974e2b7d5 100644 --- a/libs/angular/src/vault/components/attachments.component.ts +++ b/libs/angular/src/vault/components/attachments.component.ts @@ -73,7 +73,9 @@ export class AttachmentsComponent implements OnInit { try { this.formPromise = this.saveCipherAttachment(files[0]); this.cipherDomain = await this.formPromise; - this.cipher = await this.cipherDomain.decrypt(); + this.cipher = await this.cipherDomain.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain) + ); this.platformUtilsService.showToast("success", null, this.i18nService.t("attachmentSaved")); this.onUploadedAttachment.emit(); } catch (e) { @@ -179,7 +181,9 @@ export class AttachmentsComponent implements OnInit { protected async init() { this.cipherDomain = await this.loadCipher(); - this.cipher = await this.cipherDomain.decrypt(); + this.cipher = await this.cipherDomain.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain) + ); const canAccessPremium = await this.stateService.getCanAccessPremium(); this.canAccessAttachments = canAccessPremium || this.cipher.organizationId != null; @@ -229,7 +233,9 @@ export class AttachmentsComponent implements OnInit { decBuf, admin ); - this.cipher = await this.cipherDomain.decrypt(); + this.cipher = await this.cipherDomain.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(this.cipherDomain) + ); // 3. Delete old this.deletePromises[attachment.id] = this.deleteCipherAttachment(attachment.id); diff --git a/libs/angular/src/vault/components/password-history.component.ts b/libs/angular/src/vault/components/password-history.component.ts index 8fbea9d8fb9..3a25b6930a8 100644 --- a/libs/angular/src/vault/components/password-history.component.ts +++ b/libs/angular/src/vault/components/password-history.component.ts @@ -33,7 +33,9 @@ export class PasswordHistoryComponent implements OnInit { protected async init() { const cipher = await this.cipherService.get(this.cipherId); - const decCipher = await cipher.decrypt(); + const decCipher = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher) + ); this.history = decCipher.passwordHistory == null ? [] : decCipher.passwordHistory; } } diff --git a/libs/angular/src/vault/components/view.component.ts b/libs/angular/src/vault/components/view.component.ts index 75d2f500891..56c236a1208 100644 --- a/libs/angular/src/vault/components/view.component.ts +++ b/libs/angular/src/vault/components/view.component.ts @@ -114,7 +114,9 @@ export class ViewComponent implements OnDestroy, OnInit { this.cleanUp(); const cipher = await this.cipherService.get(this.cipherId); - this.cipher = await cipher.decrypt(); + this.cipher = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher) + ); this.canAccessPremium = await this.stateService.getCanAccessPremium(); this.showPremiumRequiredTotp = this.cipher.login.totp && !this.canAccessPremium && !this.cipher.organizationUseTotp; diff --git a/libs/common/src/models/export/cipher.export.ts b/libs/common/src/models/export/cipher.export.ts index 342cf59fd86..3ae6c9757dd 100644 --- a/libs/common/src/models/export/cipher.export.ts +++ b/libs/common/src/models/export/cipher.export.ts @@ -88,6 +88,7 @@ export class CipherExport { domain.notes = req.notes != null ? new EncString(req.notes) : null; domain.favorite = req.favorite; domain.reprompt = req.reprompt ?? CipherRepromptType.None; + domain.key = req.key != null ? new EncString(req.key) : null; if (req.fields != null) { domain.fields = req.fields.map((f) => FieldExport.toDomain(f)); @@ -135,6 +136,7 @@ export class CipherExport { revisionDate: Date = null; creationDate: Date = null; deletedDate: Date = null; + key: string; // Use build method instead of ctor so that we can control order of JSON stringify for pretty print build(o: CipherView | CipherDomain) { @@ -149,6 +151,7 @@ export class CipherExport { } else { this.name = o.name?.encryptedString; this.notes = o.notes?.encryptedString; + this.key = o.key?.encryptedString; } this.favorite = o.favorite; diff --git a/libs/common/src/platform/abstractions/config/config.service.abstraction.ts b/libs/common/src/platform/abstractions/config/config.service.abstraction.ts index 59f87b0fa29..67f7f2f4ce7 100644 --- a/libs/common/src/platform/abstractions/config/config.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/config/config.service.abstraction.ts @@ -1,4 +1,5 @@ import { Observable } from "rxjs"; +import { SemVer } from "semver"; import { FeatureFlag } from "../../../enums/feature-flag.enum"; import { Region } from "../environment.service"; @@ -16,6 +17,9 @@ export abstract class ConfigServiceAbstraction { key: FeatureFlag, defaultValue?: T ) => Promise; + checkServerMeetsVersionRequirement$: ( + minimumRequiredServerVersion: SemVer + ) => Observable; /** * Force ConfigService to fetch an updated config from the server and emit it from serverConfig$ diff --git a/libs/common/src/platform/abstractions/crypto.service.ts b/libs/common/src/platform/abstractions/crypto.service.ts index a868484bd04..8f1d9c48662 100644 --- a/libs/common/src/platform/abstractions/crypto.service.ts +++ b/libs/common/src/platform/abstractions/crypto.service.ts @@ -6,6 +6,7 @@ import { KeySuffixOptions, KdfType, HashPurpose } from "../../enums"; import { EncArrayBuffer } from "../models/domain/enc-array-buffer"; import { EncString } from "../models/domain/enc-string"; import { + CipherKey, MasterKey, OrgKey, PinKey, @@ -372,6 +373,11 @@ export abstract class CryptoService { */ rsaDecrypt: (encValue: string, privateKeyValue?: Uint8Array) => Promise; randomNumber: (min: number, max: number) => Promise; + /** + * Generates a new cipher key + * @returns A new cipher key + */ + makeCipherKey: () => Promise; /** * Initialize all necessary crypto keys needed for a new account. diff --git a/libs/common/src/platform/interfaces/decryptable.interface.ts b/libs/common/src/platform/interfaces/decryptable.interface.ts index ae5e8ebbf82..35895bfd6ff 100644 --- a/libs/common/src/platform/interfaces/decryptable.interface.ts +++ b/libs/common/src/platform/interfaces/decryptable.interface.ts @@ -8,5 +8,5 @@ import { InitializerMetadata } from "./initializer-metadata.interface"; * @example Cipher implements Decryptable */ export interface Decryptable extends InitializerMetadata { - decrypt: (key?: SymmetricCryptoKey) => Promise; + decrypt: (key: SymmetricCryptoKey) => Promise; } diff --git a/libs/common/src/platform/misc/flags.ts b/libs/common/src/platform/misc/flags.ts index c1bdafaa0f3..53609505675 100644 --- a/libs/common/src/platform/misc/flags.ts +++ b/libs/common/src/platform/misc/flags.ts @@ -3,6 +3,7 @@ export type SharedFlags = { multithreadDecryption: boolean; showPasswordless?: boolean; + enableCipherKeyEncryption?: boolean; }; // required to avoid linting errors when there are no flags diff --git a/libs/common/src/platform/models/domain/symmetric-crypto-key.ts b/libs/common/src/platform/models/domain/symmetric-crypto-key.ts index 818155ef98d..8f3b46f077c 100644 --- a/libs/common/src/platform/models/domain/symmetric-crypto-key.ts +++ b/libs/common/src/platform/models/domain/symmetric-crypto-key.ts @@ -83,3 +83,4 @@ export type MasterKey = Opaque; export type PinKey = Opaque; export type OrgKey = Opaque; export type ProviderKey = Opaque; +export type CipherKey = Opaque; diff --git a/libs/common/src/platform/services/config/config.service.ts b/libs/common/src/platform/services/config/config.service.ts index 008f5a764d3..45db66af0eb 100644 --- a/libs/common/src/platform/services/config/config.service.ts +++ b/libs/common/src/platform/services/config/config.service.ts @@ -10,6 +10,7 @@ import { merge, timer, } from "rxjs"; +import { SemVer } from "semver"; import { AuthService } from "../../../auth/abstractions/auth.service"; import { AuthenticationStatus } from "../../../auth/enums/authentication-status"; @@ -25,10 +26,13 @@ import { ServerConfigData } from "../../models/data/server-config.data"; const ONE_HOUR_IN_MILLISECONDS = 1000 * 3600; export class ConfigService implements ConfigServiceAbstraction { + private inited = false; + protected _serverConfig = new ReplaySubject(1); serverConfig$ = this._serverConfig.asObservable(); + private _forceFetchConfig = new Subject(); - private inited = false; + protected refreshTimer$ = timer(ONE_HOUR_IN_MILLISECONDS, ONE_HOUR_IN_MILLISECONDS); // after 1 hour, then every hour cloudRegion$ = this.serverConfig$.pipe( map((config) => config?.environment?.cloudRegion ?? Region.US) @@ -62,7 +66,7 @@ export class ConfigService implements ConfigServiceAbstraction { // If you need to fetch a new config when an event occurs, add an observable that emits on that event here merge( - timer(ONE_HOUR_IN_MILLISECONDS, ONE_HOUR_IN_MILLISECONDS), // after 1 hour, then every hour + this.refreshTimer$, // an overridable interval this.environmentService.urls, // when environment URLs change (including when app is started) this._forceFetchConfig // manual ) @@ -103,4 +107,21 @@ export class ConfigService implements ConfigServiceAbstraction { await this.stateService.setServerConfig(data); this.environmentService.setCloudWebVaultUrl(data.environment?.cloudRegion); } + + /** + * Verifies whether the server version meets the minimum required version + * @param minimumRequiredServerVersion The minimum version required + * @returns True if the server version is greater than or equal to the minimum required version + */ + checkServerMeetsVersionRequirement$(minimumRequiredServerVersion: SemVer) { + return this.serverConfig$.pipe( + map((serverConfig) => { + if (serverConfig == null) { + return false; + } + const serverVersion = new SemVer(serverConfig.version); + return serverVersion.compare(minimumRequiredServerVersion) >= 0; + }) + ); + } } diff --git a/libs/common/src/platform/services/crypto.service.ts b/libs/common/src/platform/services/crypto.service.ts index 3b4090ef344..0ea9acc53da 100644 --- a/libs/common/src/platform/services/crypto.service.ts +++ b/libs/common/src/platform/services/crypto.service.ts @@ -27,6 +27,7 @@ import { EFFLongWordList } from "../misc/wordlist"; import { EncArrayBuffer } from "../models/domain/enc-array-buffer"; import { EncString } from "../models/domain/enc-string"; import { + CipherKey, MasterKey, OrgKey, PinKey, @@ -596,6 +597,11 @@ export class CryptoService implements CryptoServiceAbstraction { return new SymmetricCryptoKey(sendKey); } + async makeCipherKey(): Promise { + const randomBytes = await this.cryptoFunctionService.aesGenerateKey(512); + return new SymmetricCryptoKey(randomBytes) as CipherKey; + } + async clearKeys(userId?: string): Promise { await this.clearUserKey(true, userId); await this.clearMasterKeyHash(userId); diff --git a/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts b/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts index 2b7563b2c39..c50326e1fa4 100644 --- a/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts +++ b/libs/common/src/tools/generator/username/email-forwarders/anon-addy-forwarder.ts @@ -11,6 +11,10 @@ export class AnonAddyForwarder implements Forwarder { if (options.anonaddy?.domain == null || options.anonaddy.domain === "") { throw "Invalid addy.io domain."; } + if (options.anonaddy?.baseUrl == null || options.anonaddy.baseUrl === "") { + throw "Invalid addy.io url."; + } + const requestInit: RequestInit = { redirect: "manual", cache: "no-store", @@ -21,7 +25,7 @@ export class AnonAddyForwarder implements Forwarder { "X-Requested-With": "XMLHttpRequest", }), }; - const url = "https://app.addy.io/api/v1/aliases"; + const url = options.anonaddy.baseUrl + "/api/v1/aliases"; requestInit.body = JSON.stringify({ domain: options.anonaddy.domain, description: @@ -37,6 +41,9 @@ export class AnonAddyForwarder implements Forwarder { if (response.status === 401) { throw "Invalid addy.io API token."; } + if (response?.statusText != null) { + throw "addy.io error:\n" + response.statusText; + } throw "Unknown addy.io error occurred."; } } diff --git a/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts b/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts index 0340eedca8f..cca6dd34dd1 100644 --- a/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts +++ b/libs/common/src/tools/generator/username/email-forwarders/forwarder-options.ts @@ -12,6 +12,7 @@ export class FastmailForwarderOptions { export class AnonAddyForwarderOptions { domain: string; + baseUrl: string; } export class ForwardEmailForwarderOptions { diff --git a/libs/common/src/tools/generator/username/username-generation-options.ts b/libs/common/src/tools/generator/username/username-generation-options.ts index 96b6e2ef1be..970f7e945e3 100644 --- a/libs/common/src/tools/generator/username/username-generation-options.ts +++ b/libs/common/src/tools/generator/username/username-generation-options.ts @@ -10,6 +10,7 @@ export type UsernameGeneratorOptions = { forwardedService?: string; forwardedAnonAddyApiToken?: string; forwardedAnonAddyDomain?: string; + forwardedAnonAddyBaseUrl?: string; forwardedDuckDuckGoToken?: string; forwardedFirefoxApiToken?: string; forwardedFastmailApiToken?: string; diff --git a/libs/common/src/tools/generator/username/username-generation.service.ts b/libs/common/src/tools/generator/username/username-generation.service.ts index b1fed147db0..35a3a73da90 100644 --- a/libs/common/src/tools/generator/username/username-generation.service.ts +++ b/libs/common/src/tools/generator/username/username-generation.service.ts @@ -24,6 +24,7 @@ const DefaultOptions: UsernameGeneratorOptions = { catchallType: "random", forwardedService: "", forwardedAnonAddyDomain: "anonaddy.me", + forwardedAnonAddyBaseUrl: "https://app.addy.io", forwardedForwardEmailDomain: "hideaddress.net", }; @@ -131,6 +132,7 @@ export class UsernameGenerationService implements UsernameGenerationServiceAbstr forwarder = new AnonAddyForwarder(); forwarderOptions.apiKey = o.forwardedAnonAddyApiToken; forwarderOptions.anonaddy.domain = o.forwardedAnonAddyDomain; + forwarderOptions.anonaddy.baseUrl = o.forwardedAnonAddyBaseUrl; } else if (o.forwardedService === "firefoxrelay") { forwarder = new FirefoxRelayForwarder(); forwarderOptions.apiKey = o.forwardedFirefoxApiToken; diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index e328c5bd491..404a58abb1a 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -11,7 +11,8 @@ export abstract class CipherService { clearCache: (userId?: string) => Promise; encrypt: ( model: CipherView, - key?: SymmetricCryptoKey, + keyForEncryption?: SymmetricCryptoKey, + keyForCipherKeyDecryption?: SymmetricCryptoKey, originalCipher?: Cipher ) => Promise; encryptFields: (fieldsModel: FieldView[], key: SymmetricCryptoKey) => Promise; @@ -81,4 +82,5 @@ export abstract class CipherService { organizationId?: string, asAdmin?: boolean ) => Promise; + getKeyForCipherKeyDecryption: (cipher: Cipher) => Promise; } diff --git a/libs/common/src/vault/models/data/cipher.data.ts b/libs/common/src/vault/models/data/cipher.data.ts index 2f83ee194b4..1452ffe7ee0 100644 --- a/libs/common/src/vault/models/data/cipher.data.ts +++ b/libs/common/src/vault/models/data/cipher.data.ts @@ -33,6 +33,7 @@ export class CipherData { creationDate: string; deletedDate: string; reprompt: CipherRepromptType; + key: string; constructor(response?: CipherResponse, collectionIds?: string[]) { if (response == null) { @@ -54,6 +55,7 @@ export class CipherData { this.creationDate = response.creationDate; this.deletedDate = response.deletedDate; this.reprompt = response.reprompt; + this.key = response.key; switch (this.type) { case CipherType.Login: diff --git a/libs/common/src/vault/models/domain/cipher.spec.ts b/libs/common/src/vault/models/domain/cipher.spec.ts index b4d50c99829..a4379f4d86a 100644 --- a/libs/common/src/vault/models/domain/cipher.spec.ts +++ b/libs/common/src/vault/models/domain/cipher.spec.ts @@ -2,10 +2,14 @@ import { Substitute, Arg } from "@fluffy-spoon/substitute"; import { Jsonify } from "type-fest"; -import { mockEnc, mockFromJson } from "../../../../spec"; +import { makeStaticByteArray, mockEnc, mockFromJson } from "../../../../spec/utils"; import { FieldType, SecureNoteType, UriMatchType } from "../../../enums"; +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { EncString } from "../../../platform/models/domain/enc-string"; +import { ContainerService } from "../../../platform/services/container.service"; import { InitializerKey } from "../../../platform/services/cryptography/initializer-key"; +import { CipherService } from "../../abstractions/cipher.service"; import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherType } from "../../enums/cipher-type"; import { CipherData } from "../../models/data/cipher.data"; @@ -47,6 +51,7 @@ describe("Cipher DTO", () => { attachments: null, fields: null, passwordHistory: null, + key: null, }); }); @@ -69,6 +74,7 @@ describe("Cipher DTO", () => { creationDate: "2022-01-01T12:00:00.000Z", deletedDate: null, reprompt: CipherRepromptType.None, + key: "EncryptedString", login: { uris: [{ uri: "EncryptedString", match: UriMatchType.Domain }], username: "EncryptedString", @@ -137,6 +143,7 @@ describe("Cipher DTO", () => { creationDate: new Date("2022-01-01T12:00:00.000Z"), deletedDate: null, reprompt: 0, + key: { encryptedString: "EncryptedString", encryptionType: 0 }, login: { passwordRevisionDate: new Date("2022-01-31T12:00:00.000Z"), autofillOnPageLoad: false, @@ -208,6 +215,7 @@ describe("Cipher DTO", () => { cipher.creationDate = new Date("2022-01-01T12:00:00.000Z"); cipher.deletedDate = null; cipher.reprompt = CipherRepromptType.None; + cipher.key = mockEnc("EncKey"); const loginView = new LoginView(); loginView.username = "username"; @@ -217,7 +225,20 @@ describe("Cipher DTO", () => { login.decrypt(Arg.any(), Arg.any()).resolves(loginView); cipher.login = login; - const cipherView = await cipher.decrypt(); + const cryptoService = Substitute.for(); + const encryptService = Substitute.for(); + const cipherService = Substitute.for(); + + encryptService.decryptToBytes(Arg.any(), Arg.any()).resolves(makeStaticByteArray(64)); + + (window as any).bitwardenContainerService = new ContainerService( + cryptoService, + encryptService + ); + + const cipherView = await cipher.decrypt( + await cipherService.getKeyForCipherKeyDecryption(cipher) + ); expect(cipherView).toMatchObject({ id: "id", @@ -263,6 +284,7 @@ describe("Cipher DTO", () => { creationDate: "2022-01-01T12:00:00.000Z", deletedDate: null, reprompt: CipherRepromptType.None, + key: "EncKey", secureNote: { type: SecureNoteType.Generic, }, @@ -294,6 +316,7 @@ describe("Cipher DTO", () => { attachments: null, fields: null, passwordHistory: null, + key: { encryptedString: "EncKey", encryptionType: 0 }, }); }); @@ -320,8 +343,22 @@ describe("Cipher DTO", () => { cipher.reprompt = CipherRepromptType.None; cipher.secureNote = new SecureNote(); cipher.secureNote.type = SecureNoteType.Generic; + cipher.key = mockEnc("EncKey"); - const cipherView = await cipher.decrypt(); + const cryptoService = Substitute.for(); + const encryptService = Substitute.for(); + const cipherService = Substitute.for(); + + encryptService.decryptToBytes(Arg.any(), Arg.any()).resolves(makeStaticByteArray(64)); + + (window as any).bitwardenContainerService = new ContainerService( + cryptoService, + encryptService + ); + + const cipherView = await cipher.decrypt( + await cipherService.getKeyForCipherKeyDecryption(cipher) + ); expect(cipherView).toMatchObject({ id: "id", @@ -375,6 +412,7 @@ describe("Cipher DTO", () => { expYear: "EncryptedString", code: "EncryptedString", }, + key: "EncKey", }; }); @@ -410,6 +448,7 @@ describe("Cipher DTO", () => { attachments: null, fields: null, passwordHistory: null, + key: { encryptedString: "EncKey", encryptionType: 0 }, }); }); @@ -434,6 +473,7 @@ describe("Cipher DTO", () => { cipher.creationDate = new Date("2022-01-01T12:00:00.000Z"); cipher.deletedDate = null; cipher.reprompt = CipherRepromptType.None; + cipher.key = mockEnc("EncKey"); const cardView = new CardView(); cardView.cardholderName = "cardholderName"; @@ -443,7 +483,20 @@ describe("Cipher DTO", () => { card.decrypt(Arg.any(), Arg.any()).resolves(cardView); cipher.card = card; - const cipherView = await cipher.decrypt(); + const cryptoService = Substitute.for(); + const encryptService = Substitute.for(); + const cipherService = Substitute.for(); + + encryptService.decryptToBytes(Arg.any(), Arg.any()).resolves(makeStaticByteArray(64)); + + (window as any).bitwardenContainerService = new ContainerService( + cryptoService, + encryptService + ); + + const cipherView = await cipher.decrypt( + await cipherService.getKeyForCipherKeyDecryption(cipher) + ); expect(cipherView).toMatchObject({ id: "id", @@ -489,6 +542,7 @@ describe("Cipher DTO", () => { creationDate: "2022-01-01T12:00:00.000Z", deletedDate: null, reprompt: CipherRepromptType.None, + key: "EncKey", identity: { title: "EncryptedString", firstName: "EncryptedString", @@ -556,6 +610,7 @@ describe("Cipher DTO", () => { attachments: null, fields: null, passwordHistory: null, + key: { encryptedString: "EncKey", encryptionType: 0 }, }); }); @@ -580,6 +635,7 @@ describe("Cipher DTO", () => { cipher.creationDate = new Date("2022-01-01T12:00:00.000Z"); cipher.deletedDate = null; cipher.reprompt = CipherRepromptType.None; + cipher.key = mockEnc("EncKey"); const identityView = new IdentityView(); identityView.firstName = "firstName"; @@ -589,7 +645,20 @@ describe("Cipher DTO", () => { identity.decrypt(Arg.any(), Arg.any()).resolves(identityView); cipher.identity = identity; - const cipherView = await cipher.decrypt(); + const cryptoService = Substitute.for(); + const encryptService = Substitute.for(); + const cipherService = Substitute.for(); + + encryptService.decryptToBytes(Arg.any(), Arg.any()).resolves(makeStaticByteArray(64)); + + (window as any).bitwardenContainerService = new ContainerService( + cryptoService, + encryptService + ); + + const cipherView = await cipher.decrypt( + await cipherService.getKeyForCipherKeyDecryption(cipher) + ); expect(cipherView).toMatchObject({ id: "id", diff --git a/libs/common/src/vault/models/domain/cipher.ts b/libs/common/src/vault/models/domain/cipher.ts index f85b6cb45c9..23349695a72 100644 --- a/libs/common/src/vault/models/domain/cipher.ts +++ b/libs/common/src/vault/models/domain/cipher.ts @@ -1,6 +1,7 @@ import { Jsonify } from "type-fest"; import { Decryptable } from "../../../platform/interfaces/decryptable.interface"; +import { Utils } from "../../../platform/misc/utils"; import Domain from "../../../platform/models/domain/domain-base"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; @@ -45,6 +46,7 @@ export class Cipher extends Domain implements Decryptable { creationDate: Date; deletedDate: Date; reprompt: CipherRepromptType; + key: EncString; constructor(obj?: CipherData, localData: LocalData = null) { super(); @@ -61,6 +63,7 @@ export class Cipher extends Domain implements Decryptable { folderId: null, name: null, notes: null, + key: null, }, ["id", "organizationId", "folderId"] ); @@ -117,9 +120,17 @@ export class Cipher extends Domain implements Decryptable { } } - async decrypt(encKey?: SymmetricCryptoKey): Promise { + // We are passing the organizationId into the EncString.decrypt() method here, but because the encKey will always be + // present and so the organizationId will not be used. + // We will refactor the EncString.decrypt() in https://bitwarden.atlassian.net/browse/PM-3762 to remove the dependency on the organizationId. + async decrypt(encKey: SymmetricCryptoKey): Promise { const model = new CipherView(this); + if (this.key != null) { + const encryptService = Utils.getContainerService().getEncryptService(); + encKey = new SymmetricCryptoKey(await encryptService.decryptToBytes(this.key, encKey)); + } + await this.decryptObj( model, { @@ -147,14 +158,12 @@ export class Cipher extends Domain implements Decryptable { break; } - const orgId = this.organizationId; - if (this.attachments != null && this.attachments.length > 0) { const attachments: any[] = []; await this.attachments.reduce((promise, attachment) => { return promise .then(() => { - return attachment.decrypt(orgId, encKey); + return attachment.decrypt(this.organizationId, encKey); }) .then((decAttachment) => { attachments.push(decAttachment); @@ -168,7 +177,7 @@ export class Cipher extends Domain implements Decryptable { await this.fields.reduce((promise, field) => { return promise .then(() => { - return field.decrypt(orgId, encKey); + return field.decrypt(this.organizationId, encKey); }) .then((decField) => { fields.push(decField); @@ -182,7 +191,7 @@ export class Cipher extends Domain implements Decryptable { await this.passwordHistory.reduce((promise, ph) => { return promise .then(() => { - return ph.decrypt(orgId, encKey); + return ph.decrypt(this.organizationId, encKey); }) .then((decPh) => { passwordHistory.push(decPh); @@ -209,6 +218,7 @@ export class Cipher extends Domain implements Decryptable { c.creationDate = this.creationDate != null ? this.creationDate.toISOString() : null; c.deletedDate = this.deletedDate != null ? this.deletedDate.toISOString() : null; c.reprompt = this.reprompt; + c.key = this.key?.encryptedString; this.buildDataModel(this, c, { name: null, @@ -257,6 +267,7 @@ export class Cipher extends Domain implements Decryptable { const attachments = obj.attachments?.map((a: any) => Attachment.fromJSON(a)); const fields = obj.fields?.map((f: any) => Field.fromJSON(f)); const passwordHistory = obj.passwordHistory?.map((ph: any) => Password.fromJSON(ph)); + const key = EncString.fromJSON(obj.key); Object.assign(domain, obj, { name, @@ -266,6 +277,7 @@ export class Cipher extends Domain implements Decryptable { attachments, fields, passwordHistory, + key, }); switch (obj.type) { diff --git a/libs/common/src/vault/models/request/cipher.request.ts b/libs/common/src/vault/models/request/cipher.request.ts index 1bab28f6474..dde20cceb63 100644 --- a/libs/common/src/vault/models/request/cipher.request.ts +++ b/libs/common/src/vault/models/request/cipher.request.ts @@ -30,6 +30,7 @@ export class CipherRequest { attachments2: { [id: string]: AttachmentRequest }; lastKnownRevisionDate: Date; reprompt: CipherRepromptType; + key: string; constructor(cipher: Cipher) { this.type = cipher.type; @@ -40,6 +41,7 @@ export class CipherRequest { this.favorite = cipher.favorite; this.lastKnownRevisionDate = cipher.revisionDate; this.reprompt = cipher.reprompt; + this.key = cipher.key?.encryptedString; switch (this.type) { case CipherType.Login: diff --git a/libs/common/src/vault/models/response/cipher.response.ts b/libs/common/src/vault/models/response/cipher.response.ts index 71e43373775..8bc8a37874e 100644 --- a/libs/common/src/vault/models/response/cipher.response.ts +++ b/libs/common/src/vault/models/response/cipher.response.ts @@ -32,6 +32,7 @@ export class CipherResponse extends BaseResponse { creationDate: string; deletedDate: string; reprompt: CipherRepromptType; + key: string; constructor(response: any) { super(response); @@ -90,5 +91,6 @@ export class CipherResponse extends BaseResponse { } this.reprompt = this.getResponseProperty("Reprompt") || CipherRepromptType.None; + this.key = this.getResponseProperty("Key") || null; } } diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index 4df3d202ff2..2c9adce553b 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -1,15 +1,24 @@ -// eslint-disable-next-line no-restricted-imports import { mock, mockReset } from "jest-mock-extended"; +import { of } from "rxjs"; +import { makeStaticByteArray } from "../../../spec/utils"; import { ApiService } from "../../abstractions/api.service"; import { SearchService } from "../../abstractions/search.service"; import { SettingsService } from "../../abstractions/settings.service"; import { UriMatchType, FieldType } from "../../enums"; +import { ConfigServiceAbstraction } from "../../platform/abstractions/config/config.service.abstraction"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { StateService } from "../../platform/abstractions/state.service"; -import { OrgKey, SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer"; +import { EncString } from "../../platform/models/domain/enc-string"; +import { + CipherKey, + OrgKey, + SymmetricCryptoKey, +} from "../../platform/models/domain/symmetric-crypto-key"; +import { ContainerService } from "../../platform/services/container.service"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; import { CipherRepromptType } from "../enums/cipher-reprompt-type"; import { CipherType } from "../enums/cipher-type"; @@ -18,9 +27,13 @@ import { Cipher } from "../models/domain/cipher"; import { CipherCreateRequest } from "../models/request/cipher-create.request"; import { CipherPartialRequest } from "../models/request/cipher-partial.request"; import { CipherRequest } from "../models/request/cipher.request"; +import { CipherView } from "../models/view/cipher.view"; import { CipherService } from "./cipher.service"; +const ENCRYPTED_TEXT = "This data has been encrypted"; +const ENCRYPTED_BYTES = mock(); + const cipherData: CipherData = { id: "id", organizationId: "orgId", @@ -35,6 +48,7 @@ const cipherData: CipherData = { notes: "EncryptedString", creationDate: "2022-01-01T12:00:00.000Z", deletedDate: null, + key: "EncKey", reprompt: CipherRepromptType.None, login: { uris: [{ uri: "EncryptedString", match: UriMatchType.Domain }], @@ -88,6 +102,7 @@ describe("Cipher Service", () => { const i18nService = mock(); const searchService = mock(); const encryptService = mock(); + const configService = mock(); let cipherService: CipherService; let cipherObj: Cipher; @@ -101,6 +116,12 @@ describe("Cipher Service", () => { mockReset(i18nService); mockReset(searchService); mockReset(encryptService); + mockReset(configService); + + encryptService.encryptToBytes.mockReturnValue(Promise.resolve(ENCRYPTED_BYTES)); + encryptService.encrypt.mockReturnValue(Promise.resolve(new EncString(ENCRYPTED_TEXT))); + + (window as any).bitwardenContainerService = new ContainerService(cryptoService, encryptService); cipherService = new CipherService( cryptoService, @@ -110,7 +131,8 @@ describe("Cipher Service", () => { searchService, stateService, encryptService, - cipherFileUploadService + cipherFileUploadService, + configService ); cipherObj = new Cipher(cipherData); @@ -125,6 +147,12 @@ describe("Cipher Service", () => { cryptoService.makeDataEncKey.mockReturnValue( Promise.resolve(new SymmetricCryptoKey(new Uint8Array(32))) ); + + configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(false)); + process.env.FLAGS = JSON.stringify({ + enableCipherKeyEncryption: false, + }); + const spy = jest.spyOn(cipherFileUploadService, "upload"); await cipherService.saveAttachmentRawWithServer(new Cipher(), fileName, fileData); @@ -216,4 +244,68 @@ describe("Cipher Service", () => { expect(spy).toHaveBeenCalledWith(cipherObj.id, expectedObj); }); }); + + describe("encrypt", () => { + let cipherView: CipherView; + + beforeEach(() => { + cipherView = new CipherView(); + cipherView.type = CipherType.Login; + + encryptService.decryptToBytes.mockReturnValue(Promise.resolve(makeStaticByteArray(64))); + configService.checkServerMeetsVersionRequirement$.mockReturnValue(of(true)); + cryptoService.makeCipherKey.mockReturnValue( + Promise.resolve(new SymmetricCryptoKey(makeStaticByteArray(64)) as CipherKey) + ); + cryptoService.encrypt.mockReturnValue(Promise.resolve(new EncString(ENCRYPTED_TEXT))); + }); + + describe("cipher.key", () => { + it("is null when enableCipherKeyEncryption flag is false", async () => { + process.env.FLAGS = JSON.stringify({ + enableCipherKeyEncryption: false, + }); + + const cipher = await cipherService.encrypt(cipherView); + + expect(cipher.key).toBeNull(); + }); + + it("is defined when enableCipherKeyEncryption flag is true", async () => { + process.env.FLAGS = JSON.stringify({ + enableCipherKeyEncryption: true, + }); + + const cipher = await cipherService.encrypt(cipherView); + + expect(cipher.key).toBeDefined(); + }); + }); + + describe("encryptWithCipherKey", () => { + beforeEach(() => { + jest.spyOn(cipherService, "encryptCipherWithCipherKey"); + }); + + it("is not called when enableCipherKeyEncryption is false", async () => { + process.env.FLAGS = JSON.stringify({ + enableCipherKeyEncryption: false, + }); + + await cipherService.encrypt(cipherView); + + expect(cipherService["encryptCipherWithCipherKey"]).not.toHaveBeenCalled(); + }); + + it("is called when enableCipherKeyEncryption is true", async () => { + process.env.FLAGS = JSON.stringify({ + enableCipherKeyEncryption: true, + }); + + await cipherService.encrypt(cipherView); + + expect(cipherService["encryptCipherWithCipherKey"]).toHaveBeenCalled(); + }); + }); + }); }); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 916690cb132..e2c27fa7297 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1,13 +1,18 @@ +import { firstValueFrom } from "rxjs"; +import { SemVer } from "semver"; + import { ApiService } from "../../abstractions/api.service"; import { SearchService } from "../../abstractions/search.service"; import { SettingsService } from "../../abstractions/settings.service"; import { FieldType, UriMatchType } from "../../enums"; import { ErrorResponse } from "../../models/response/error.response"; import { View } from "../../models/view/view"; +import { ConfigServiceAbstraction } from "../../platform/abstractions/config/config.service.abstraction"; import { CryptoService } from "../../platform/abstractions/crypto.service"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { StateService } from "../../platform/abstractions/state.service"; +import { flagEnabled } from "../../platform/misc/flags"; import { sequentialize } from "../../platform/misc/sequentialize"; import { Utils } from "../../platform/misc/utils"; import Domain from "../../platform/models/domain/domain-base"; @@ -48,6 +53,8 @@ import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; import { PasswordHistoryView } from "../models/view/password-history.view"; +const CIPHER_KEY_ENC_MIN_SERVER_VER = new SemVer("2023.9.1"); + export class CipherService implements CipherServiceAbstraction { private sortedCiphersCache: SortedCiphersCache = new SortedCiphersCache( this.sortCiphersByLastUsed @@ -61,7 +68,8 @@ export class CipherService implements CipherServiceAbstraction { private searchService: SearchService, private stateService: StateService, private encryptService: EncryptService, - private cipherFileUploadService: CipherFileUploadService + private cipherFileUploadService: CipherFileUploadService, + private configService: ConfigServiceAbstraction ) {} async getDecryptedCipherCache(): Promise { @@ -86,63 +94,18 @@ export class CipherService implements CipherServiceAbstraction { async encrypt( model: CipherView, - key?: SymmetricCryptoKey, + keyForEncryption?: SymmetricCryptoKey, + keyForCipherKeyDecryption?: SymmetricCryptoKey, originalCipher: Cipher = null ): Promise { - // Adjust password history if (model.id != null) { if (originalCipher == null) { originalCipher = await this.get(model.id); } if (originalCipher != null) { - const existingCipher = await originalCipher.decrypt(); - model.passwordHistory = existingCipher.passwordHistory || []; - if (model.type === CipherType.Login && existingCipher.type === CipherType.Login) { - if ( - existingCipher.login.password != null && - existingCipher.login.password !== "" && - existingCipher.login.password !== model.login.password - ) { - const ph = new PasswordHistoryView(); - ph.password = existingCipher.login.password; - ph.lastUsedDate = model.login.passwordRevisionDate = new Date(); - model.passwordHistory.splice(0, 0, ph); - } else { - model.login.passwordRevisionDate = existingCipher.login.passwordRevisionDate; - } - } - if (existingCipher.hasFields) { - const existingHiddenFields = existingCipher.fields.filter( - (f) => - f.type === FieldType.Hidden && - f.name != null && - f.name !== "" && - f.value != null && - f.value !== "" - ); - const hiddenFields = - model.fields == null - ? [] - : model.fields.filter( - (f) => f.type === FieldType.Hidden && f.name != null && f.name !== "" - ); - existingHiddenFields.forEach((ef) => { - const matchedField = hiddenFields.find((f) => f.name === ef.name); - if (matchedField == null || matchedField.value !== ef.value) { - const ph = new PasswordHistoryView(); - ph.password = ef.name + ": " + ef.value; - ph.lastUsedDate = new Date(); - model.passwordHistory.splice(0, 0, ph); - } - }); - } - } - if (model.passwordHistory != null && model.passwordHistory.length === 0) { - model.passwordHistory = null; - } else if (model.passwordHistory != null && model.passwordHistory.length > 5) { - // only save last 5 history - model.passwordHistory = model.passwordHistory.slice(0, 5); + await this.updateModelfromExistingCipher(model, originalCipher); } + this.adjustPasswordHistoryLength(model); } const cipher = new Cipher(); @@ -156,35 +119,32 @@ export class CipherService implements CipherServiceAbstraction { cipher.reprompt = model.reprompt; cipher.edit = model.edit; - if (key == null && cipher.organizationId != null) { - key = await this.cryptoService.getOrgKey(cipher.organizationId); - if (key == null) { - throw new Error("Cannot encrypt cipher for organization. No key."); - } - } - await Promise.all([ - this.encryptObjProperty( + if (await this.getCipherKeyEncryptionEnabled()) { + cipher.key = originalCipher?.key ?? null; + const userOrOrgKey = await this.getKeyForCipherKeyDecryption(cipher); + // The keyForEncryption is only used for encrypting the cipher key, not the cipher itself, since cipher key encryption is enabled. + // If the caller has provided a key for cipher key encryption, use it. Otherwise, use the user or org key. + keyForEncryption ||= userOrOrgKey; + // If the caller has provided a key for cipher key decryption, use it. Otherwise, use the user or org key. + keyForCipherKeyDecryption ||= userOrOrgKey; + return this.encryptCipherWithCipherKey( model, cipher, - { - name: null, - notes: null, - }, - key - ), - this.encryptCipherData(cipher, model, key), - this.encryptFields(model.fields, key).then((fields) => { - cipher.fields = fields; - }), - this.encryptPasswordHistories(model.passwordHistory, key).then((ph) => { - cipher.passwordHistory = ph; - }), - this.encryptAttachments(model.attachments, key).then((attachments) => { - cipher.attachments = attachments; - }), - ]); - - return cipher; + keyForEncryption, + keyForCipherKeyDecryption + ); + } else { + if (keyForEncryption == null && cipher.organizationId != null) { + keyForEncryption = await this.cryptoService.getOrgKey(cipher.organizationId); + if (keyForEncryption == null) { + throw new Error("Cannot encrypt cipher for organization. No key."); + } + } + // We want to ensure that the cipher key is null if cipher key encryption is disabled + // so that decryption uses the proper key. + cipher.key = null; + return this.encryptCipher(model, cipher, keyForEncryption); + } } async encryptAttachments( @@ -580,7 +540,7 @@ export class CipherService implements CipherServiceAbstraction { cipher.organizationId = organizationId; cipher.collectionIds = collectionIds; - const encCipher = await this.encrypt(cipher); + const encCipher = await this.encryptSharedCipher(cipher); const request = new CipherShareRequest(encCipher); const response = await this.apiService.putShareCipher(cipher.id, request); const data = new CipherData(response, collectionIds); @@ -598,7 +558,7 @@ export class CipherService implements CipherServiceAbstraction { cipher.organizationId = organizationId; cipher.collectionIds = collectionIds; promises.push( - this.encrypt(cipher).then((c) => { + this.encryptSharedCipher(cipher).then((c) => { encCiphers.push(c); }) ); @@ -646,14 +606,29 @@ export class CipherService implements CipherServiceAbstraction { data: Uint8Array, admin = false ): Promise { - let encKey: UserKey | OrgKey; - encKey = await this.cryptoService.getOrgKey(cipher.organizationId); - encKey ||= await this.cryptoService.getUserKeyWithLegacySupport(); + const encKey = await this.getKeyForCipherKeyDecryption(cipher); + const cipherKeyEncryptionEnabled = await this.getCipherKeyEncryptionEnabled(); - const dataEncKey = await this.cryptoService.makeDataEncKey(encKey); + const cipherEncKey = + cipherKeyEncryptionEnabled && cipher.key != null + ? (new SymmetricCryptoKey( + await this.encryptService.decryptToBytes(cipher.key, encKey) + ) as UserKey) + : encKey; - const encFileName = await this.encryptService.encrypt(filename, encKey); - const encData = await this.encryptService.encryptToBytes(data, dataEncKey[0]); + //if cipher key encryption is disabled but the item has an individual key, + //then we rollback to using the user key as the main key of encryption of the item + //in order to keep item and it's attachments with the same encryption level + if (cipher.key != null && !cipherKeyEncryptionEnabled) { + const model = await cipher.decrypt(await this.getKeyForCipherKeyDecryption(cipher)); + cipher = await this.encrypt(model); + await this.updateWithServer(cipher); + } + + const encFileName = await this.encryptService.encrypt(filename, cipherEncKey); + + const dataEncKey = await this.cryptoService.makeDataEncKey(cipherEncKey); + const encData = await this.encryptService.encryptToBytes(new Uint8Array(data), dataEncKey[0]); const response = await this.cipherFileUploadService.upload( cipher, @@ -964,8 +939,80 @@ export class CipherService implements CipherServiceAbstraction { await this.restore(restores); } + async getKeyForCipherKeyDecryption(cipher: Cipher): Promise { + return ( + (await this.cryptoService.getOrgKey(cipher.organizationId)) || + ((await this.cryptoService.getUserKeyWithLegacySupport()) as UserKey) + ); + } + // Helpers + // In the case of a cipher that is being shared with an organization, we want to decrypt the + // cipher key with the user's key and then re-encrypt it with the organization's key. + private async encryptSharedCipher(model: CipherView): Promise { + const keyForCipherKeyDecryption = await this.cryptoService.getUserKeyWithLegacySupport(); + return await this.encrypt(model, null, keyForCipherKeyDecryption); + } + + private async updateModelfromExistingCipher( + model: CipherView, + originalCipher: Cipher + ): Promise { + const existingCipher = await originalCipher.decrypt( + await this.getKeyForCipherKeyDecryption(originalCipher) + ); + model.passwordHistory = existingCipher.passwordHistory || []; + if (model.type === CipherType.Login && existingCipher.type === CipherType.Login) { + if ( + existingCipher.login.password != null && + existingCipher.login.password !== "" && + existingCipher.login.password !== model.login.password + ) { + const ph = new PasswordHistoryView(); + ph.password = existingCipher.login.password; + ph.lastUsedDate = model.login.passwordRevisionDate = new Date(); + model.passwordHistory.splice(0, 0, ph); + } else { + model.login.passwordRevisionDate = existingCipher.login.passwordRevisionDate; + } + } + if (existingCipher.hasFields) { + const existingHiddenFields = existingCipher.fields.filter( + (f) => + f.type === FieldType.Hidden && + f.name != null && + f.name !== "" && + f.value != null && + f.value !== "" + ); + const hiddenFields = + model.fields == null + ? [] + : model.fields.filter( + (f) => f.type === FieldType.Hidden && f.name != null && f.name !== "" + ); + existingHiddenFields.forEach((ef) => { + const matchedField = hiddenFields.find((f) => f.name === ef.name); + if (matchedField == null || matchedField.value !== ef.value) { + const ph = new PasswordHistoryView(); + ph.password = ef.name + ": " + ef.value; + ph.lastUsedDate = new Date(); + model.passwordHistory.splice(0, 0, ph); + } + }); + } + } + + private adjustPasswordHistoryLength(model: CipherView) { + if (model.passwordHistory != null && model.passwordHistory.length === 0) { + model.passwordHistory = null; + } else if (model.passwordHistory != null && model.passwordHistory.length > 5) { + // only save last 5 history + model.passwordHistory = model.passwordHistory.slice(0, 5); + } + } + private async shareAttachmentWithServer( attachmentView: AttachmentView, cipherId: string, @@ -1226,4 +1273,69 @@ export class CipherService implements CipherServiceAbstraction { private clearSortedCiphers() { this.sortedCiphersCache.clear(); } + + private async encryptCipher( + model: CipherView, + cipher: Cipher, + key: SymmetricCryptoKey + ): Promise { + await Promise.all([ + this.encryptObjProperty( + model, + cipher, + { + name: null, + notes: null, + }, + key + ), + this.encryptCipherData(cipher, model, key), + this.encryptFields(model.fields, key).then((fields) => { + cipher.fields = fields; + }), + this.encryptPasswordHistories(model.passwordHistory, key).then((ph) => { + cipher.passwordHistory = ph; + }), + this.encryptAttachments(model.attachments, key).then((attachments) => { + cipher.attachments = attachments; + }), + ]); + + return cipher; + } + + private async encryptCipherWithCipherKey( + model: CipherView, + cipher: Cipher, + keyForCipherKeyEncryption: SymmetricCryptoKey, + keyForCipherKeyDecryption: SymmetricCryptoKey + ): Promise { + // First, we get the key for cipher key encryption, in its decrypted form + let decryptedCipherKey: SymmetricCryptoKey; + if (cipher.key == null) { + decryptedCipherKey = await this.cryptoService.makeCipherKey(); + } else { + decryptedCipherKey = new SymmetricCryptoKey( + await this.encryptService.decryptToBytes(cipher.key, keyForCipherKeyDecryption) + ); + } + + // Then, we have to encrypt the cipher key with the proper key. + cipher.key = await this.encryptService.encrypt( + decryptedCipherKey.key, + keyForCipherKeyEncryption + ); + + // Finally, we can encrypt the cipher with the decrypted cipher key. + return this.encryptCipher(model, cipher, decryptedCipherKey); + } + + private async getCipherKeyEncryptionEnabled(): Promise { + return ( + flagEnabled("enableCipherKeyEncryption") && + (await firstValueFrom( + this.configService.checkServerMeetsVersionRequirement$(CIPHER_KEY_ENC_MIN_SERVER_VER) + )) + ); + } } diff --git a/libs/components/src/checkbox/checkbox.component.ts b/libs/components/src/checkbox/checkbox.component.ts index 8e966fa2272..5e1e17a5cac 100644 --- a/libs/components/src/checkbox/checkbox.component.ts +++ b/libs/components/src/checkbox/checkbox.component.ts @@ -44,21 +44,33 @@ export class CheckboxComponent implements BitFormControlAbstraction { "checked:tw-bg-primary-500", "checked:tw-border-primary-500", - "checked:hover:tw-bg-primary-700", "checked:hover:tw-border-primary-700", "[&>label:hover]:checked:tw-bg-primary-700", "[&>label:hover]:checked:tw-border-primary-700", - "checked:before:tw-bg-text-contrast", - "checked:before:tw-mask-image-[var(--mask-image)]", "checked:before:tw-mask-position-[center]", "checked:before:tw-mask-repeat-[no-repeat]", - "checked:disabled:tw-border-secondary-100", "checked:disabled:tw-bg-secondary-100", - "checked:disabled:before:tw-bg-text-muted", + + "[&:not(:indeterminate)]:checked:before:tw-mask-image-[var(--mask-image)]", + "indeterminate:before:tw-mask-image-[var(--indeterminate-mask-image)]", + + "indeterminate:tw-bg-primary-500", + "indeterminate:tw-border-primary-500", + "indeterminate:hover:tw-bg-primary-700", + "indeterminate:hover:tw-border-primary-700", + "[&>label:hover]:indeterminate:tw-bg-primary-700", + "[&>label:hover]:indeterminate:tw-border-primary-700", + "indeterminate:before:tw-bg-text-contrast", + "indeterminate:before:tw-mask-position-[center]", + "indeterminate:before:tw-mask-repeat-[no-repeat]", + "indeterminate:before:tw-mask-image-[var(--indeterminate-mask-image)]", + "indeterminate:disabled:tw-border-secondary-100", + "indeterminate:disabled:tw-bg-secondary-100", + "indeterminate:disabled:before:tw-bg-text-muted", ]; constructor(@Optional() @Self() private ngControl?: NgControl) {} @@ -66,6 +78,9 @@ export class CheckboxComponent implements BitFormControlAbstraction { @HostBinding("style.--mask-image") protected maskImage = `url('data:image/svg+xml,%3Csvg class="svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="8" height="8" viewBox="0 0 10 10"%3E%3Cpath d="M0.5 6.2L2.9 8.6L9.5 1.4" fill="none" stroke="white" stroke-width="2"%3E%3C/path%3E%3C/svg%3E')`; + @HostBinding("style.--indeterminate-mask-image") + protected indeterminateImage = `url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="none" viewBox="0 0 13 13"%3E%3Cpath stroke="%23fff" stroke-width="2" d="M2.5 6.5h8"/%3E%3C/svg%3E%0A')`; + @HostBinding() @Input() get disabled() { diff --git a/libs/components/src/checkbox/checkbox.stories.ts b/libs/components/src/checkbox/checkbox.stories.ts index c2c924e2a33..11ef32eac77 100644 --- a/libs/components/src/checkbox/checkbox.stories.ts +++ b/libs/components/src/checkbox/checkbox.stories.ts @@ -110,3 +110,12 @@ export const Custom: Story = { `, }), }; + +export const Indeterminate: Story = { + render: (args) => ({ + props: args, + template: ` + + `, + }), +}; diff --git a/libs/exporter/src/vault-export/services/vault-export.service.ts b/libs/exporter/src/vault-export/services/vault-export.service.ts index 8609ce0d1c8..c214646d9aa 100644 --- a/libs/exporter/src/vault-export/services/vault-export.service.ts +++ b/libs/exporter/src/vault-export/services/vault-export.service.ts @@ -258,12 +258,15 @@ export class VaultExportService implements VaultExportServiceAbstraction { if (exportData.ciphers != null && exportData.ciphers.length > 0) { exportData.ciphers .filter((c) => c.deletedDate === null) - .forEach((c) => { + .forEach(async (c) => { const cipher = new Cipher(new CipherData(c)); exportPromises.push( - cipher.decrypt().then((decCipher) => { - decCiphers.push(decCipher); - }) + this.cipherService + .getKeyForCipherKeyDecryption(cipher) + .then((key) => cipher.decrypt(key)) + .then((decCipher) => { + decCiphers.push(decCipher); + }) ); }); } diff --git a/libs/importer/spec/bitwarden-password-protected-importer.spec.ts b/libs/importer/spec/bitwarden-password-protected-importer.spec.ts index 0f8f6418628..5d24cef4140 100644 --- a/libs/importer/spec/bitwarden-password-protected-importer.spec.ts +++ b/libs/importer/spec/bitwarden-password-protected-importer.spec.ts @@ -4,6 +4,7 @@ import { KdfType } from "@bitwarden/common/enums"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { BitwardenPasswordProtectedImporter, @@ -17,6 +18,7 @@ describe("BitwardenPasswordProtectedImporter", () => { let importer: BitwardenPasswordProtectedImporter; let cryptoService: MockProxy; let i18nService: MockProxy; + let cipherService: MockProxy; const password = Utils.newGuid(); const promptForPassword_callback = async () => { return password; @@ -25,10 +27,12 @@ describe("BitwardenPasswordProtectedImporter", () => { beforeEach(() => { cryptoService = mock(); i18nService = mock(); + cipherService = mock(); importer = new BitwardenPasswordProtectedImporter( cryptoService, i18nService, + cipherService, promptForPassword_callback ); }); diff --git a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts index 5c281dc6b73..895b64e91ce 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts @@ -6,6 +6,7 @@ import { import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { @@ -25,7 +26,8 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { protected constructor( protected cryptoService: CryptoService, - protected i18nService: I18nService + protected i18nService: I18nService, + protected cipherService: CipherService ) { super(); } @@ -96,7 +98,9 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { }); } - const view = await cipher.decrypt(); + const view = await cipher.decrypt( + await this.cipherService.getKeyForCipherKeyDecryption(cipher) + ); this.cleanupCipher(view); this.result.ciphers.push(view); } diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts index a8c2a711a0b..49288e9dd8c 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts @@ -4,21 +4,24 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { BitwardenPasswordProtectedFileFormat } from "@bitwarden/exporter/vault-export/bitwarden-json-export-types"; import { ImportResult } from "../../models/import-result"; import { Importer } from "../importer"; import { BitwardenJsonImporter } from "./bitwarden-json-importer"; + export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter implements Importer { private key: SymmetricCryptoKey; constructor( cryptoService: CryptoService, i18nService: I18nService, + cipherService: CipherService, private promptForPassword_callback: () => Promise ) { - super(cryptoService, i18nService); + super(cryptoService, i18nService, cipherService); } async parse(data: string): Promise { diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 4ff15174c56..437e51436c5 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -203,6 +203,7 @@ export class ImportService implements ImportServiceAbstraction { return new BitwardenPasswordProtectedImporter( this.cryptoService, this.i18nService, + this.cipherService, promptForPassword_callback ); case "lastpasscsv": diff --git a/package-lock.json b/package-lock.json index d5e40478d20..3b5803da99b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,6 @@ "chalk": "4.1.2", "commander": "7.2.0", "core-js": "3.32.0", - "date-input-polyfill": "2.14.0", "duo_web_sdk": "github:duosecurity/duo_web_sdk", "form-data": "4.0.0", "https-proxy-agent": "5.0.1", @@ -261,7 +260,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2023.9.0" + "version": "2023.9.1" }, "libs/angular": { "name": "@bitwarden/angular", @@ -16726,27 +16725,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/babel-runtime": { - "version": "6.26.0", - "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", - "integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==", - "dependencies": { - "core-js": "^2.4.0", - "regenerator-runtime": "^0.11.0" - } - }, - "node_modules/babel-runtime/node_modules/core-js": { - "version": "2.6.12", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", - "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", - "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", - "hasInstallScript": true - }, - "node_modules/babel-runtime/node_modules/regenerator-runtime": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", - "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" - }, "node_modules/bach": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/bach/-/bach-1.2.0.tgz", @@ -19247,14 +19225,6 @@ "node": ">=6.9.0" } }, - "node_modules/date-input-polyfill": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/date-input-polyfill/-/date-input-polyfill-2.14.0.tgz", - "integrity": "sha512-LUfuBYYlayDyBbQCIMN1RyrDaTmy5pa3u3jIDoWTXk/7tPgOajZczjWZA2ITd/+lbhtUBM6fhT+Grxs1yYATVA==", - "dependencies": { - "babel-runtime": "^6.11.6" - } - }, "node_modules/debounce-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", diff --git a/package.json b/package.json index c7c0a64360b..8c9f8e9b14c 100644 --- a/package.json +++ b/package.json @@ -171,7 +171,6 @@ "chalk": "4.1.2", "commander": "7.2.0", "core-js": "3.32.0", - "date-input-polyfill": "2.14.0", "duo_web_sdk": "github:duosecurity/duo_web_sdk", "form-data": "4.0.0", "https-proxy-agent": "5.0.1",