mirror of
https://github.com/bitwarden/browser
synced 2025-12-13 06:43:35 +00:00
Merge branch 'main' into PM-12985-Reports
This commit is contained in:
18
.github/renovate.json
vendored
18
.github/renovate.json
vendored
@@ -75,10 +75,12 @@
|
||||
},
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"@emotion/css",
|
||||
"@webcomponents/custom-elements",
|
||||
"concurrently",
|
||||
"cross-env",
|
||||
"del",
|
||||
"lit",
|
||||
"nord",
|
||||
"patch-package",
|
||||
"prettier",
|
||||
@@ -102,6 +104,8 @@
|
||||
"matchPackageNames": [
|
||||
"@babel/core",
|
||||
"@babel/preset-env",
|
||||
"@bitwarden/sdk-internal",
|
||||
"@electron/fuses",
|
||||
"@electron/notarize",
|
||||
"@electron/rebuild",
|
||||
"@ngtools/webpack",
|
||||
@@ -113,7 +117,7 @@
|
||||
"@types/node",
|
||||
"@types/node-forge",
|
||||
"@types/node-ipc",
|
||||
"@yao-pkg",
|
||||
"@yao-pkg/pkg",
|
||||
"babel-loader",
|
||||
"browserslist",
|
||||
"copy-webpack-plugin",
|
||||
@@ -133,6 +137,7 @@
|
||||
"tsconfig-paths-webpack-plugin",
|
||||
"type-fest",
|
||||
"typescript",
|
||||
"typescript-strict-plugin",
|
||||
"webpack",
|
||||
"webpack-cli",
|
||||
"webpack-dev-server",
|
||||
@@ -149,12 +154,13 @@
|
||||
"@angular/cdk",
|
||||
"@angular/cli",
|
||||
"@angular/common",
|
||||
"@angular/compiler",
|
||||
"@angular/compiler-cli",
|
||||
"@angular/compiler",
|
||||
"@angular/core",
|
||||
"@angular/forms",
|
||||
"@angular/platform-browser-dynamic",
|
||||
"@angular/platform-browser",
|
||||
"@angular/platform",
|
||||
"@angular/compiler",
|
||||
"@angular/router",
|
||||
"@compodoc/compodoc",
|
||||
"@ng-select/ng-select",
|
||||
@@ -162,8 +168,11 @@
|
||||
"@storybook/addon-actions",
|
||||
"@storybook/addon-designs",
|
||||
"@storybook/addon-essentials",
|
||||
"@storybook/addon-interactions",
|
||||
"@storybook/addon-links",
|
||||
"@storybook/angular",
|
||||
"@storybook/manager-api",
|
||||
"@storybook/theming",
|
||||
"@types/react",
|
||||
"autoprefixer",
|
||||
"bootstrap",
|
||||
@@ -186,7 +195,9 @@
|
||||
"matchPackageNames": [
|
||||
"@angular-eslint/eslint-plugin",
|
||||
"@angular-eslint/eslint-plugin-template",
|
||||
"@angular-eslint/schematics",
|
||||
"@angular-eslint/template-parser",
|
||||
"@angular/elements",
|
||||
"@types/jest",
|
||||
"@typescript-eslint/eslint-plugin",
|
||||
"@typescript-eslint/parser",
|
||||
@@ -199,6 +210,7 @@
|
||||
"eslint-plugin-storybook",
|
||||
"eslint-plugin-tailwindcss",
|
||||
"husky",
|
||||
"jest-extended",
|
||||
"jest-junit",
|
||||
"jest-mock-extended",
|
||||
"jest-preset-angular",
|
||||
|
||||
3
.github/workflows/build-desktop.yml
vendored
3
.github/workflows/build-desktop.yml
vendored
@@ -1196,6 +1196,8 @@ jobs:
|
||||
uses: slackapi/slack-github-action@485a9d42d3a73031f12ec201c457e2162c45d02d # v2.0.0
|
||||
with:
|
||||
channel-id: C074F5UESQ0
|
||||
method: chat.postMessage
|
||||
token: ${{ steps.retrieve-slack-secret.outputs.slack-bot-token }}
|
||||
payload: |
|
||||
{
|
||||
"blocks": [
|
||||
@@ -1209,7 +1211,6 @@ jobs:
|
||||
]
|
||||
}
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ steps.retrieve-slack-secret.outputs.slack-bot-token }}
|
||||
BUILD_NUMBER: ${{ needs.setup.outputs.build_number }}
|
||||
|
||||
|
||||
|
||||
130
.github/workflows/deploy-web.yml
vendored
130
.github/workflows/deploy-web.yml
vendored
@@ -63,14 +63,14 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
environment: ${{ steps.config.outputs.environment }}
|
||||
environment-url: ${{ steps.config.outputs.environment-url }}
|
||||
environment-name: ${{ steps.config.outputs.environment-name }}
|
||||
environment-artifact: ${{ steps.config.outputs.environment-artifact }}
|
||||
azure-login-creds: ${{ steps.config.outputs.azure-login-creds }}
|
||||
retrieve-secrets-keyvault: ${{ steps.config.outputs.retrieve-secrets-keyvault }}
|
||||
sync-utility: ${{ steps.config.outputs.sync-utility }}
|
||||
sync-delete-destination-files: ${{ steps.config.outputs.sync-delete-destination-files }}
|
||||
slack-channel-name: ${{ steps.config.outputs.slack-channel-name }}
|
||||
environment_url: ${{ steps.config.outputs.environment_url }}
|
||||
environment_name: ${{ steps.config.outputs.environment_name }}
|
||||
environment_artifact: ${{ steps.config.outputs.environment_artifact }}
|
||||
azure_login_creds: ${{ steps.config.outputs.azure_login_creds }}
|
||||
retrive_secrets_keyvault: ${{ steps.config.outputs.retrive_secrets_keyvault }}
|
||||
sync_utility: ${{ steps.config.outputs.sync_utility }}
|
||||
sync_delete_destination_files: ${{ steps.config.outputs.sync_delete_destination_files }}
|
||||
slack_channel_name: ${{ steps.config.outputs.slack-channel-name }}
|
||||
steps:
|
||||
- name: Configure
|
||||
id: config
|
||||
@@ -81,48 +81,48 @@ jobs:
|
||||
|
||||
case ${{ inputs.environment }} in
|
||||
"USQA")
|
||||
echo "azure-login-creds=AZURE_KV_US_QA_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
|
||||
echo "retrieve-secrets-keyvault=bw-webvault-rlktusqa-kv" >> $GITHUB_OUTPUT
|
||||
echo "environment-artifact=web-*-cloud-QA.zip" >> $GITHUB_OUTPUT
|
||||
echo "environment-name=Web Vault - US QA Cloud" >> $GITHUB_OUTPUT
|
||||
echo "environment-url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT
|
||||
echo "slack-channel-name=alerts-deploy-qa" >> $GITHUB_OUTPUT
|
||||
echo "azure_login_creds=AZURE_KV_US_QA_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
|
||||
echo "retrive_secrets_keyvault=bw-webvault-rlktusqa-kv" >> $GITHUB_OUTPUT
|
||||
echo "environment_artifact=web-*-cloud-QA.zip" >> $GITHUB_OUTPUT
|
||||
echo "environment_name=Web Vault - US QA Cloud" >> $GITHUB_OUTPUT
|
||||
echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT
|
||||
echo "slack_channel_name=alerts-deploy-qa" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"EUQA")
|
||||
echo "azure-login-creds=AZURE_KV_EU_QA_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
|
||||
echo "retrieve-secrets-keyvault=webvaulteu-westeurope-qa" >> $GITHUB_OUTPUT
|
||||
echo "environment-artifact=web-*-cloud-euqa.zip" >> $GITHUB_OUTPUT
|
||||
echo "environment-name=Web Vault - EU QA Cloud" >> $GITHUB_OUTPUT
|
||||
echo "environment-url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT
|
||||
echo "slack-channel-name=alerts-deploy-qa" >> $GITHUB_OUTPUT
|
||||
echo "azure_login_creds=AZURE_KV_EU_QA_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
|
||||
echo "retrive_secrets_keyvault=webvaulteu-westeurope-qa" >> $GITHUB_OUTPUT
|
||||
echo "environment_artifact=web-*-cloud-euqa.zip" >> $GITHUB_OUTPUT
|
||||
echo "environment_name=Web Vault - EU QA Cloud" >> $GITHUB_OUTPUT
|
||||
echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT
|
||||
echo "slack_channel_name=alerts-deploy-qa" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"USPROD")
|
||||
echo "azure-login-creds=AZURE_KV_US_PROD_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
|
||||
echo "retrieve-secrets-keyvault=bw-webvault-klrt-kv" >> $GITHUB_OUTPUT
|
||||
echo "environment-artifact=web-*-cloud-COMMERCIAL.zip" >> $GITHUB_OUTPUT
|
||||
echo "environment-name=Web Vault - US Production Cloud" >> $GITHUB_OUTPUT
|
||||
echo "environment-url=http://vault.bitwarden.com" >> $GITHUB_OUTPUT
|
||||
echo "slack-channel-name=alerts-deploy-prd" >> $GITHUB_OUTPUT
|
||||
echo "azure_login_creds=AZURE_KV_US_PROD_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
|
||||
echo "retrive_secrets_keyvault=bw-webvault-klrt-kv" >> $GITHUB_OUTPUT
|
||||
echo "environment_artifact=web-*-cloud-COMMERCIAL.zip" >> $GITHUB_OUTPUT
|
||||
echo "environment_name=Web Vault - US Production Cloud" >> $GITHUB_OUTPUT
|
||||
echo "environment_url=http://vault.bitwarden.com" >> $GITHUB_OUTPUT
|
||||
echo "slack_channel_name=alerts-deploy-prd" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"EUPROD")
|
||||
echo "azure-login-creds=AZURE_KV_EU_PRD_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
|
||||
echo "retrieve-secrets-keyvault=webvault-westeurope-prod" >> $GITHUB_OUTPUT
|
||||
echo "environment-artifact=web-*-cloud-euprd.zip" >> $GITHUB_OUTPUT
|
||||
echo "environment-name=Web Vault - EU Production Cloud" >> $GITHUB_OUTPUT
|
||||
echo "environment-url=http://vault.bitwarden.eu" >> $GITHUB_OUTPUT
|
||||
echo "slack-channel-name=alerts-deploy-prd" >> $GITHUB_OUTPUT
|
||||
echo "azure_login_creds=AZURE_KV_EU_PRD_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
|
||||
echo "retrive_secrets_keyvault=webvault-westeurope-prod" >> $GITHUB_OUTPUT
|
||||
echo "environment_artifact=web-*-cloud-euprd.zip" >> $GITHUB_OUTPUT
|
||||
echo "environment_name=Web Vault - EU Production Cloud" >> $GITHUB_OUTPUT
|
||||
echo "environment_url=http://vault.bitwarden.eu" >> $GITHUB_OUTPUT
|
||||
echo "slack_channel_name=alerts-deploy-prd" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
"USDEV")
|
||||
echo "azure-login-creds=AZURE_KV_US_DEV_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
|
||||
echo "retrieve-secrets-keyvault=webvault-eastus-dev" >> $GITHUB_OUTPUT
|
||||
echo "environment-artifact=web-*-cloud-usdev.zip" >> $GITHUB_OUTPUT
|
||||
echo "environment-name=Web Vault - US Development Cloud" >> $GITHUB_OUTPUT
|
||||
echo "environment-url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT
|
||||
echo "slack-channel-name=alerts-deploy-dev" >> $GITHUB_OUTPUT
|
||||
echo "azure_login_creds=AZURE_KV_US_DEV_SERVICE_PRINCIPAL" >> $GITHUB_OUTPUT
|
||||
echo "retrive_secrets_keyvault=webvault-eastus-dev" >> $GITHUB_OUTPUT
|
||||
echo "environment_artifact=web-*-cloud-usdev.zip" >> $GITHUB_OUTPUT
|
||||
echo "environment_name=Web Vault - US Development Cloud" >> $GITHUB_OUTPUT
|
||||
echo "environment_url=http://vault.$ENV_NAME_LOWER.bitwarden.pw" >> $GITHUB_OUTPUT
|
||||
echo "slack_channel_name=alerts-deploy-dev" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
# Set the sync utility to use for deployment to the environment (az-sync or azcopy)
|
||||
echo "sync-utility=azcopy" >> $GITHUB_OUTPUT
|
||||
echo "sync_utility=azcopy" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Environment Protection
|
||||
env:
|
||||
@@ -168,10 +168,10 @@ jobs:
|
||||
fi
|
||||
|
||||
approval:
|
||||
name: Approval for Deployment to ${{ needs.setup.outputs.environment-name }}
|
||||
name: Approval for Deployment to ${{ needs.setup.outputs.environment_name }}
|
||||
needs: setup
|
||||
runs-on: ubuntu-22.04
|
||||
environment: ${{ needs.setup.outputs.environment-name }}
|
||||
environment: ${{ needs.setup.outputs.environment_name }}
|
||||
steps:
|
||||
- name: Success Code
|
||||
run: exit 0
|
||||
@@ -181,9 +181,9 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
env:
|
||||
_ENVIRONMENT_ARTIFACT: ${{ needs.setup.outputs.environment-artifact }}
|
||||
_ENVIRONMENT_ARTIFACT: ${{ needs.setup.outputs.environment_artifact }}
|
||||
outputs:
|
||||
artifact-build-commit: ${{ steps.set-artifact-commit.outputs.commit }}
|
||||
artifact_build_commit: ${{ steps.set-artifact-commit.outputs.commit }}
|
||||
steps:
|
||||
- name: 'Download latest cloud asset using GitHub Run ID: ${{ inputs.build-web-run-id }}'
|
||||
if: ${{ inputs.build-web-run-id }}
|
||||
@@ -242,7 +242,7 @@ jobs:
|
||||
run: |
|
||||
# If run-id was used, get the commit from the download-latest-artifacts-run-id step
|
||||
if [ "${{ inputs.build-web-run-id }}" ]; then
|
||||
echo "commit=${{ steps.download-latest-artifacts-run-id.outputs.artifact-build-commit }}" >> $GITHUB_OUTPUT
|
||||
echo "commit=${{ steps.download-latest-artifacts-run-id.outputs.artifact_build_commit }}" >> $GITHUB_OUTPUT
|
||||
|
||||
elif [ "${{ steps.download-latest-artifacts.outcome }}" == "failure" ]; then
|
||||
# If the download-latest-artifacts step failed, query the GH API to get the commit SHA of the artifact that was just built with trigger-build-web.
|
||||
@@ -251,7 +251,7 @@ jobs:
|
||||
|
||||
else
|
||||
# Set the commit to the output of step download-latest-artifacts.
|
||||
echo "commit=${{ steps.download-latest-artifacts.outputs.artifact-build-commit }}" >> $GITHUB_OUTPUT
|
||||
echo "commit=${{ steps.download-latest-artifacts.outputs.artifact_build_commit }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
notify-start:
|
||||
@@ -271,11 +271,11 @@ jobs:
|
||||
id: slack-message
|
||||
with:
|
||||
project: Clients
|
||||
environment: ${{ needs.setup.outputs.environment-name }}
|
||||
environment: ${{ needs.setup.outputs.environment_name }}
|
||||
tag: ${{ inputs.branch-or-tag }}
|
||||
slack-channel: ${{ needs.setup.outputs.slack-channel-name }}
|
||||
slack-channel: ${{ needs.setup.outputs.slack_channel_name }}
|
||||
event: 'start'
|
||||
commit-sha: ${{ needs.artifact-check.outputs.artifact-build-commit }}
|
||||
commit-sha: ${{ needs.artifact-check.outputs.artifact_build_commit }}
|
||||
url: https://github.com/bitwarden/clients/actions/runs/${{ github.run_id }}
|
||||
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
@@ -287,7 +287,7 @@ jobs:
|
||||
- name: Display commit SHA
|
||||
run: |
|
||||
REPO_URL="https://github.com/bitwarden/clients/commit"
|
||||
COMMIT_SHA="${{ needs.artifact-check.outputs.artifact-build-commit }}"
|
||||
COMMIT_SHA="${{ needs.artifact-check.outputs.artifact_build_commit }}"
|
||||
echo ":steam_locomotive: View [commit]($REPO_URL/$COMMIT_SHA)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
azure-deploy:
|
||||
@@ -299,9 +299,9 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
_ENVIRONMENT: ${{ needs.setup.outputs.environment }}
|
||||
_ENVIRONMENT_URL: ${{ needs.setup.outputs.environment-url }}
|
||||
_ENVIRONMENT_NAME: ${{ needs.setup.outputs.environment-name }}
|
||||
_ENVIRONMENT_ARTIFACT: ${{ needs.setup.outputs.environment-artifact }}
|
||||
_ENVIRONMENT_URL: ${{ needs.setup.outputs.environment_url }}
|
||||
_ENVIRONMENT_NAME: ${{ needs.setup.outputs.environment_name }}
|
||||
_ENVIRONMENT_ARTIFACT: ${{ needs.setup.outputs.environment_artifact }}
|
||||
steps:
|
||||
- name: Create GitHub deployment
|
||||
uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7
|
||||
@@ -309,31 +309,31 @@ jobs:
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
initial-status: 'in_progress'
|
||||
environment-url: ${{ env._ENVIRONMENT_URL }}
|
||||
environment_url: ${{ env._ENVIRONMENT_URL }}
|
||||
environment: ${{ env._ENVIRONMENT_NAME }}
|
||||
task: 'deploy'
|
||||
description: 'Deployment from branch/tag: ${{ inputs.branch-or-tag }}'
|
||||
ref: ${{ needs.artifact-check.outputs.artifact-build-commit }}
|
||||
ref: ${{ needs.artifact-check.outputs.artifact_build_commit }}
|
||||
|
||||
- name: Login to Azure
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
with:
|
||||
creds: ${{ secrets[needs.setup.outputs.azure-login-creds] }}
|
||||
creds: ${{ secrets[needs.setup.outputs.azure_login_creds] }}
|
||||
|
||||
- name: Retrieve Storage Account connection string for az sync
|
||||
if: ${{ needs.setup.outputs.sync-utility == 'az-sync' }}
|
||||
if: ${{ needs.setup.outputs.sync_utility == 'az-sync' }}
|
||||
id: retrieve-secrets-az-sync
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: ${{ needs.setup.outputs.retrieve-secrets-keyvault }}
|
||||
keyvault: ${{ needs.setup.outputs.retrive_secrets_keyvault }}
|
||||
secrets: "sa-bitwarden-web-vault-dev-key-temp"
|
||||
|
||||
- name: Retrieve Storage Account name and SPN credentials for azcopy
|
||||
if: ${{ needs.setup.outputs.sync-utility == 'azcopy' }}
|
||||
if: ${{ needs.setup.outputs.sync_utility == 'azcopy' }}
|
||||
id: retrieve-secrets-azcopy
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: ${{ needs.setup.outputs.retrieve-secrets-keyvault }}
|
||||
keyvault: ${{ needs.setup.outputs.retrive_secrets_keyvault }}
|
||||
secrets: "sa-bitwarden-web-vault-name,sp-bitwarden-web-vault-password,sp-bitwarden-web-vault-appid,sp-bitwarden-web-vault-tenant"
|
||||
|
||||
- name: 'Download latest cloud asset using GitHub Run ID: ${{ inputs.build-web-run-id }}'
|
||||
@@ -363,7 +363,7 @@ jobs:
|
||||
run: unzip ${{ env._ENVIRONMENT_ARTIFACT }}
|
||||
|
||||
- name: Sync to Azure Storage Account using az storage blob sync
|
||||
if: ${{ needs.setup.outputs.sync-utility == 'az-sync' }}
|
||||
if: ${{ needs.setup.outputs.sync_utility == 'az-sync' }}
|
||||
working-directory: apps/web
|
||||
run: |
|
||||
az storage blob sync \
|
||||
@@ -373,7 +373,7 @@ jobs:
|
||||
--delete-destination=${{ inputs.force-delete-destination }}
|
||||
|
||||
- name: Sync to Azure Storage Account using azcopy
|
||||
if: ${{ needs.setup.outputs.sync-utility == 'azcopy' }}
|
||||
if: ${{ needs.setup.outputs.sync_utility == 'azcopy' }}
|
||||
working-directory: apps/web
|
||||
env:
|
||||
AZCOPY_AUTO_LOGIN_TYPE: SPN
|
||||
@@ -397,7 +397,7 @@ jobs:
|
||||
uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
environment-url: ${{ env._ENVIRONMENT_URL }}
|
||||
environment_url: ${{ env._ENVIRONMENT_URL }}
|
||||
state: 'success'
|
||||
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
|
||||
|
||||
@@ -406,7 +406,7 @@ jobs:
|
||||
uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
environment-url: ${{ env._ENVIRONMENT_URL }}
|
||||
environment_url: ${{ env._ENVIRONMENT_URL }}
|
||||
state: 'failure'
|
||||
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
|
||||
|
||||
@@ -424,11 +424,11 @@ jobs:
|
||||
uses: bitwarden/gh-actions/report-deployment-status-to-slack@main
|
||||
with:
|
||||
project: Clients
|
||||
environment: ${{ needs.setup.outputs.environment-name }}
|
||||
environment: ${{ needs.setup.outputs.environment_name }}
|
||||
tag: ${{ inputs.branch-or-tag }}
|
||||
slack-channel: ${{ needs.notify-start.outputs.channel_id }}
|
||||
event: ${{ needs.azure-deploy.result }}
|
||||
url: https://github.com/bitwarden/clients/actions/runs/${{ github.run_id }}
|
||||
commit-sha: ${{ needs.artifact-check.outputs.artifact-build-commit }}
|
||||
commit-sha: ${{ needs.artifact-check.outputs.artifact_build_commit }}
|
||||
update-ts: ${{ needs.notify-start.outputs.ts }}
|
||||
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
|
||||
18
.github/workflows/lint.yml
vendored
18
.github/workflows/lint.yml
vendored
@@ -54,21 +54,25 @@ jobs:
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
node-version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint unowned dependencies
|
||||
run: npm run lint:dep-ownership
|
||||
|
||||
- name: Run linter
|
||||
run: |
|
||||
npm ci
|
||||
npm run lint
|
||||
run: npm run lint
|
||||
|
||||
rust:
|
||||
name: Run Rust lint on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os || 'ubuntu-latest' }}
|
||||
runs-on: ${{ matrix.os || 'ubuntu-24.04' }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
- ubuntu-24.04
|
||||
- macos-14
|
||||
- windows-2022
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
|
||||
14
.github/workflows/publish-cli.yml
vendored
14
.github/workflows/publish-cli.yml
vendored
@@ -43,8 +43,8 @@ jobs:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release-version: ${{ steps.version-output.outputs.version }}
|
||||
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
|
||||
release_version: ${{ steps.version-output.outputs.version }}
|
||||
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
needs: setup
|
||||
if: inputs.snap_publish
|
||||
env:
|
||||
_PKG_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
_PKG_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
needs: setup
|
||||
if: inputs.choco_publish
|
||||
env:
|
||||
_PKG_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
_PKG_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
needs: setup
|
||||
if: inputs.npm_publish
|
||||
env:
|
||||
_PKG_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
_PKG_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
state: 'success'
|
||||
deployment-id: ${{ needs.setup.outputs.deployment-id }}
|
||||
deployment_id: ${{ needs.setup.outputs.deployment_id }}
|
||||
|
||||
- name: Update deployment status to Failure
|
||||
if: ${{ inputs.publish_type != 'Dry Run' && failure() }}
|
||||
@@ -230,4 +230,4 @@ jobs:
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
state: 'failure'
|
||||
deployment-id: ${{ needs.setup.outputs.deployment-id }}
|
||||
deployment_id: ${{ needs.setup.outputs.deployment_id }}
|
||||
|
||||
34
.github/workflows/publish-desktop.yml
vendored
34
.github/workflows/publish-desktop.yml
vendored
@@ -39,10 +39,10 @@ jobs:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release-version: ${{ steps.version.outputs.version }}
|
||||
release-channel: ${{ steps.release-channel.outputs.channel }}
|
||||
tag-name: ${{ steps.version.outputs.tag_name }}
|
||||
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
|
||||
release_version: ${{ steps.version.outputs.version }}
|
||||
release_channel: ${{ steps.release_channel.outputs.channel }}
|
||||
tag_name: ${{ steps.version.outputs.tag_name }}
|
||||
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
|
||||
steps:
|
||||
- name: Branch check
|
||||
if: ${{ inputs.publish_type != 'Dry Run' }}
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Get Version Channel
|
||||
id: release-channel
|
||||
id: release_channel
|
||||
run: |
|
||||
case "${{ steps.version.outputs.version }}" in
|
||||
*"alpha"*)
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
initial-status: 'in_progress'
|
||||
environment: 'Desktop - Production'
|
||||
description: 'Deployment ${{ steps.version.outputs.version }} to channel ${{ steps.release-channel.outputs.channel }} from branch ${{ github.ref_name }}'
|
||||
description: 'Deployment ${{ steps.version.outputs.version }} to channel ${{ steps.release_channel.outputs.channel }} from branch ${{ github.ref_name }}'
|
||||
task: release
|
||||
|
||||
electron-blob:
|
||||
@@ -108,8 +108,8 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
env:
|
||||
_PKG_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
_RELEASE_TAG: ${{ needs.setup.outputs.tag-name }}
|
||||
_PKG_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
_RELEASE_TAG: ${{ needs.setup.outputs.tag_name }}
|
||||
steps:
|
||||
- name: Login to Azure
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
@@ -136,7 +136,7 @@ jobs:
|
||||
|
||||
- name: Set staged rollout percentage
|
||||
env:
|
||||
RELEASE_CHANNEL: ${{ needs.setup.outputs.release-channel }}
|
||||
RELEASE_CHANNEL: ${{ needs.setup.outputs.release_channel }}
|
||||
ROLLOUT_PCT: ${{ inputs.rollout_percentage }}
|
||||
run: |
|
||||
echo "stagingPercentage: ${ROLLOUT_PCT}" >> apps/desktop/artifacts/${RELEASE_CHANNEL}.yml
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
state: 'success'
|
||||
deployment-id: ${{ needs.setup.outputs.deployment-id }}
|
||||
deployment_id: ${{ needs.setup.outputs.deployment_id }}
|
||||
|
||||
- name: Update deployment status to Failure
|
||||
if: ${{ inputs.publish_type != 'Dry Run' && failure() }}
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
state: 'failure'
|
||||
deployment-id: ${{ needs.setup.outputs.deployment-id }}
|
||||
deployment_id: ${{ needs.setup.outputs.deployment_id }}
|
||||
|
||||
snap:
|
||||
name: Deploy Snap
|
||||
@@ -179,8 +179,8 @@ jobs:
|
||||
needs: setup
|
||||
if: inputs.snap_publish
|
||||
env:
|
||||
_PKG_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
_RELEASE_TAG: ${{ needs.setup.outputs.tag-name }}
|
||||
_PKG_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
_RELEASE_TAG: ${{ needs.setup.outputs.tag_name }}
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -223,8 +223,8 @@ jobs:
|
||||
needs: setup
|
||||
if: inputs.choco_publish
|
||||
env:
|
||||
_PKG_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
_RELEASE_TAG: ${{ needs.setup.outputs.tag-name }}
|
||||
_PKG_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
_RELEASE_TAG: ${{ needs.setup.outputs.tag_name }}
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -284,7 +284,7 @@ jobs:
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
state: 'success'
|
||||
deployment-id: ${{ needs.setup.outputs.deployment-id }}
|
||||
deployment_id: ${{ needs.setup.outputs.deployment_id }}
|
||||
|
||||
- name: Update deployment status to Failure
|
||||
if: ${{ inputs.publish_type != 'Dry Run' && failure() }}
|
||||
@@ -292,4 +292,4 @@ jobs:
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
state: 'failure'
|
||||
deployment-id: ${{ needs.setup.outputs.deployment-id }}
|
||||
deployment_id: ${{ needs.setup.outputs.deployment_id }}
|
||||
|
||||
20
.github/workflows/release-browser.yml
vendored
20
.github/workflows/release-browser.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release-version: ${{ steps.version.outputs.version }}
|
||||
release_version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
- name: Check Release Version
|
||||
id: version
|
||||
uses: bitwarden/gh-actions/release-version-check@main
|
||||
uses: bitwarden/gh-actions/release_version-check@main
|
||||
with:
|
||||
release-type: ${{ github.event.inputs.release_type }}
|
||||
project-type: ts
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
|
||||
- name: Rename build artifacts
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
run: |
|
||||
mv browser-source.zip browser-source-$PACKAGE_VERSION.zip
|
||||
mv dist-chrome.zip dist-chrome-$PACKAGE_VERSION.zip
|
||||
@@ -130,14 +130,14 @@ jobs:
|
||||
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
|
||||
uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0
|
||||
with:
|
||||
artifacts: 'browser-source-${{ needs.setup.outputs.release-version }}.zip,
|
||||
dist-chrome-${{ needs.setup.outputs.release-version }}.zip,
|
||||
dist-opera-${{ needs.setup.outputs.release-version }}.zip,
|
||||
dist-firefox-${{ needs.setup.outputs.release-version }}.zip,
|
||||
dist-edge-${{ needs.setup.outputs.release-version }}.zip'
|
||||
artifacts: 'browser-source-${{ needs.setup.outputs.release_version }}.zip,
|
||||
dist-chrome-${{ needs.setup.outputs.release_version }}.zip,
|
||||
dist-opera-${{ needs.setup.outputs.release_version }}.zip,
|
||||
dist-firefox-${{ needs.setup.outputs.release_version }}.zip,
|
||||
dist-edge-${{ needs.setup.outputs.release_version }}.zip'
|
||||
commit: ${{ github.sha }}
|
||||
tag: "browser-v${{ needs.setup.outputs.release-version }}"
|
||||
name: "Browser v${{ needs.setup.outputs.release-version }}"
|
||||
tag: "browser-v${{ needs.setup.outputs.release_version }}"
|
||||
name: "Browser v${{ needs.setup.outputs.release_version }}"
|
||||
body: "<insert release notes here>"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
draft: true
|
||||
|
||||
6
.github/workflows/release-cli.yml
vendored
6
.github/workflows/release-cli.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release-version: ${{ steps.version.outputs.version }}
|
||||
release_version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
- name: Check Release Version
|
||||
id: version
|
||||
uses: bitwarden/gh-actions/release-version-check@main
|
||||
uses: bitwarden/gh-actions/release_version-check@main
|
||||
with:
|
||||
release-type: ${{ inputs.release_type }}
|
||||
project-type: ts
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
if: ${{ inputs.release_type != 'Dry Run' }}
|
||||
uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0
|
||||
env:
|
||||
PKG_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
PKG_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
with:
|
||||
artifacts: "apps/cli/bw-oss-windows-${{ env.PKG_VERSION }}.zip,
|
||||
apps/cli/bw-oss-windows-sha256-${{ env.PKG_VERSION }}.txt,
|
||||
|
||||
50
.github/workflows/release-desktop-beta.yml
vendored
50
.github/workflows/release-desktop-beta.yml
vendored
@@ -16,9 +16,9 @@ jobs:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release-version: ${{ steps.version.outputs.version }}
|
||||
release-channel: ${{ steps.release-channel.outputs.channel }}
|
||||
branch-name: ${{ steps.branch.outputs.branch-name }}
|
||||
release_version: ${{ steps.version.outputs.version }}
|
||||
release_channel: ${{ steps.release_channel.outputs.channel }}
|
||||
branch_name: ${{ steps.branch.outputs.branch_name }}
|
||||
build_number: ${{ steps.increment-version.outputs.build_number }}
|
||||
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||
steps:
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
|
||||
- name: Check Release Version
|
||||
id: version
|
||||
uses: bitwarden/gh-actions/release-version-check@main
|
||||
uses: bitwarden/gh-actions/release_version-check@main
|
||||
with:
|
||||
release-type: 'Initial Release'
|
||||
project-type: ts
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
echo "build_number=$BUILD_NUMBER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Version Channel
|
||||
id: release-channel
|
||||
id: release_channel
|
||||
run: |
|
||||
case "${{ steps.version.outputs.version }}" in
|
||||
*"alpha"*)
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
|
||||
git push -u origin $branch_name
|
||||
|
||||
echo "branch-name=$branch_name" >> $GITHUB_OUTPUT
|
||||
echo "branch_name=$branch_name" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Node Version
|
||||
id: retrieve-node-version
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
env:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
NODE_OPTIONS: --max_old_space_size=4096
|
||||
defaults:
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.setup.outputs.branch-name }}
|
||||
ref: ${{ needs.setup.outputs.branch_name }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -195,8 +195,8 @@ jobs:
|
||||
- name: Upload auto-update artifact
|
||||
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
|
||||
with:
|
||||
name: ${{ needs.setup.outputs.release-channel }}-linux.yml
|
||||
path: apps/desktop/dist/${{ needs.setup.outputs.release-channel }}-linux.yml
|
||||
name: ${{ needs.setup.outputs.release_channel }}-linux.yml
|
||||
path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-linux.yml
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
@@ -209,14 +209,14 @@ jobs:
|
||||
shell: pwsh
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
NODE_OPTIONS: --max_old_space_size=4096
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.setup.outputs.branch-name }}
|
||||
ref: ${{ needs.setup.outputs.branch_name }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -385,8 +385,8 @@ jobs:
|
||||
- name: Upload auto-update artifact
|
||||
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
|
||||
with:
|
||||
name: ${{ needs.setup.outputs.release-channel }}.yml
|
||||
path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release-channel }}.yml
|
||||
name: ${{ needs.setup.outputs.release_channel }}.yml
|
||||
path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
@@ -395,7 +395,7 @@ jobs:
|
||||
runs-on: macos-13
|
||||
needs: setup
|
||||
env:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
NODE_OPTIONS: --max_old_space_size=4096
|
||||
defaults:
|
||||
@@ -405,7 +405,7 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.setup.outputs.branch-name }}
|
||||
ref: ${{ needs.setup.outputs.branch_name }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -529,7 +529,7 @@ jobs:
|
||||
- setup
|
||||
- macos-build
|
||||
env:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
NODE_OPTIONS: --max_old_space_size=4096
|
||||
defaults:
|
||||
@@ -539,7 +539,7 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.setup.outputs.branch-name }}
|
||||
ref: ${{ needs.setup.outputs.branch_name }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -730,8 +730,8 @@ jobs:
|
||||
- name: Upload auto-update artifact
|
||||
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
|
||||
with:
|
||||
name: ${{ needs.setup.outputs.release-channel }}-mac.yml
|
||||
path: apps/desktop/dist/${{ needs.setup.outputs.release-channel }}-mac.yml
|
||||
name: ${{ needs.setup.outputs.release_channel }}-mac.yml
|
||||
path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-mac.yml
|
||||
if-no-files-found: error
|
||||
|
||||
|
||||
@@ -742,7 +742,7 @@ jobs:
|
||||
- setup
|
||||
- macos-build
|
||||
env:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
NODE_OPTIONS: --max_old_space_size=4096
|
||||
defaults:
|
||||
@@ -752,7 +752,7 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ needs.setup.outputs.branch-name }}
|
||||
ref: ${{ needs.setup.outputs.branch_name }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -939,7 +939,7 @@ jobs:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
initial-status: 'in_progress'
|
||||
environment: 'Desktop - Beta'
|
||||
description: 'Deployment ${{ needs.setup.outputs.release-version }} to channel ${{ needs.setup.outputs.release-channel }} from branch ${{ needs.setup.outputs.branch-name }}'
|
||||
description: 'Deployment ${{ needs.setup.outputs.release_version }} to channel ${{ needs.setup.outputs.release_channel }} from branch ${{ needs.setup.outputs.branch_name }}'
|
||||
task: release
|
||||
|
||||
- name: Login to Azure
|
||||
@@ -963,7 +963,7 @@ jobs:
|
||||
|
||||
- name: Rename .pkg to .pkg.archive
|
||||
env:
|
||||
PKG_VERSION: ${{ needs.setup.outputs.release-version }}
|
||||
PKG_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
working-directory: apps/desktop/artifacts
|
||||
run: mv Bitwarden-${{ env.PKG_VERSION }}-universal.pkg Bitwarden-${{ env.PKG_VERSION }}-universal.pkg.archive
|
||||
|
||||
@@ -1020,5 +1020,5 @@ jobs:
|
||||
git config --global url."https://".insteadOf ssh://
|
||||
- name: Remove branch
|
||||
env:
|
||||
BRANCH: ${{ needs.setup.outputs.branch-name }}
|
||||
BRANCH: ${{ needs.setup.outputs.branch_name }}
|
||||
run: git push origin --delete $BRANCH
|
||||
|
||||
12
.github/workflows/release-desktop.yml
vendored
12
.github/workflows/release-desktop.yml
vendored
@@ -22,8 +22,8 @@ jobs:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
release-version: ${{ steps.version.outputs.version }}
|
||||
release-channel: ${{ steps.release-channel.outputs.channel }}
|
||||
release_version: ${{ steps.version.outputs.version }}
|
||||
release_channel: ${{ steps.release_channel.outputs.channel }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
- name: Check Release Version
|
||||
id: version
|
||||
uses: bitwarden/gh-actions/release-version-check@main
|
||||
uses: bitwarden/gh-actions/release_version-check@main
|
||||
with:
|
||||
release-type: ${{ inputs.release_type }}
|
||||
project-type: ts
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
monorepo-project: desktop
|
||||
|
||||
- name: Get Version Channel
|
||||
id: release-channel
|
||||
id: release_channel
|
||||
run: |
|
||||
case "${{ steps.version.outputs.version }}" in
|
||||
*"alpha"*)
|
||||
@@ -97,10 +97,10 @@ jobs:
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0
|
||||
if: ${{ steps.release-channel.outputs.channel == 'latest' && github.event.inputs.release_type != 'Dry Run' }}
|
||||
if: ${{ steps.release_channel.outputs.channel == 'latest' && github.event.inputs.release_type != 'Dry Run' }}
|
||||
env:
|
||||
PKG_VERSION: ${{ steps.version.outputs.version }}
|
||||
RELEASE_CHANNEL: ${{ steps.release-channel.outputs.channel }}
|
||||
RELEASE_CHANNEL: ${{ steps.release_channel.outputs.channel }}
|
||||
with:
|
||||
artifacts: "apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-amd64.deb,
|
||||
apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-x86_64.rpm,
|
||||
|
||||
@@ -2804,6 +2804,20 @@
|
||||
"error": {
|
||||
"message": "Error"
|
||||
},
|
||||
"decryptionError": {
|
||||
"message": "Decryption error"
|
||||
},
|
||||
"couldNotDecryptVaultItemsBelow": {
|
||||
"message": "Bitwarden could not decrypt the vault item(s) listed below."
|
||||
},
|
||||
"contactCSToAvoidDataLossPart1": {
|
||||
"message": "Contact customer success",
|
||||
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
|
||||
},
|
||||
"contactCSToAvoidDataLossPart2": {
|
||||
"message": "to avoid additional data loss.",
|
||||
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
|
||||
},
|
||||
"generateUsername": {
|
||||
"message": "Generate username"
|
||||
},
|
||||
@@ -4656,6 +4670,33 @@
|
||||
"noEditPermissions": {
|
||||
"message": "You don't have permission to edit this item"
|
||||
},
|
||||
"biometricsStatusHelptextUnlockNeeded": {
|
||||
"message": "Biometric unlock is unavailable because PIN or password unlock is required first."
|
||||
},
|
||||
"biometricsStatusHelptextHardwareUnavailable": {
|
||||
"message": "Biometric unlock is currently unavailable."
|
||||
},
|
||||
"biometricsStatusHelptextAutoSetupNeeded": {
|
||||
"message": "Biometric unlock is unavailable due to misconfigured system files."
|
||||
},
|
||||
"biometricsStatusHelptextManualSetupNeeded": {
|
||||
"message": "Biometric unlock is unavailable due to misconfigured system files."
|
||||
},
|
||||
"biometricsStatusHelptextDesktopDisconnected": {
|
||||
"message": "Biometric unlock is unavailable because the Bitwarden desktop app is closed."
|
||||
},
|
||||
"biometricsStatusHelptextNotEnabledInDesktop": {
|
||||
"message": "Biometric unlock is unavailable because it is not enabled for $EMAIL$ in the Bitwarden desktop app.",
|
||||
"placeholders": {
|
||||
"email": {
|
||||
"content": "$1",
|
||||
"example": "mail@example.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
"biometricsStatusHelptextUnavailableReasonUnknown": {
|
||||
"message": "Biometric unlock is currently unavailable for an unknown reason."
|
||||
},
|
||||
"authenticating": {
|
||||
"message": "Authenticating"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authenticatio
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { AvatarModule, ItemModule } from "@bitwarden/components";
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
import { AccountSwitcherService, AvailableAccount } from "./services/account-switcher.service";
|
||||
|
||||
@@ -26,6 +27,7 @@ export class AccountComponent {
|
||||
private location: Location,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
private biometricsService: BiometricsService,
|
||||
) {}
|
||||
|
||||
get specialAccountAddId() {
|
||||
@@ -45,6 +47,9 @@ export class AccountComponent {
|
||||
// locked or logged out account statuses are handled by background and app.component
|
||||
if (result?.status === AuthenticationStatus.Unlocked) {
|
||||
this.location.back();
|
||||
await this.biometricsService.setShouldAutopromptNow(false);
|
||||
} else {
|
||||
await this.biometricsService.setShouldAutopromptNow(true);
|
||||
}
|
||||
this.loading.emit(false);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,16 @@
|
||||
<h2 bitTypography="h6">{{ "unlockMethods" | i18n }}</h2>
|
||||
</bit-section-header>
|
||||
<bit-card>
|
||||
<bit-form-control *ngIf="supportsBiometric">
|
||||
<bit-form-control>
|
||||
<input bitCheckbox id="biometric" type="checkbox" formControlName="biometric" />
|
||||
<bit-label for="biometric" class="tw-whitespace-normal">{{
|
||||
"unlockWithBiometrics" | i18n
|
||||
}}</bit-label>
|
||||
<bit-hint *ngIf="biometricUnavailabilityReason">
|
||||
{{ biometricUnavailabilityReason }}
|
||||
</bit-hint>
|
||||
</bit-form-control>
|
||||
<bit-form-control class="tw-pl-5" *ngIf="supportsBiometric && this.form.value.biometric">
|
||||
<bit-form-control class="tw-pl-5" *ngIf="this.form.value.biometric">
|
||||
<input
|
||||
bitCheckbox
|
||||
id="autoBiometricsPrompt"
|
||||
@@ -106,18 +109,6 @@
|
||||
<i slot="end" class="bwi bwi-external-link" aria-hidden="true"></i>
|
||||
</button>
|
||||
</bit-item>
|
||||
<bit-item
|
||||
*ngIf="
|
||||
!accountSwitcherEnabled && availableVaultTimeoutActions.includes(VaultTimeoutAction.Lock)
|
||||
"
|
||||
>
|
||||
<button bit-item-content type="button" appStopClick (click)="lock()"></button>
|
||||
{{ "lockNow" | i18n }}
|
||||
</bit-item>
|
||||
<bit-item *ngIf="!accountSwitcherEnabled">
|
||||
<button bit-item-content type="button" appStopClick (click)="logOut()"></button>
|
||||
{{ "logOut" | i18n }}
|
||||
</bit-item>
|
||||
</bit-section>
|
||||
</div>
|
||||
</popup-page>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
timer,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
@@ -53,11 +54,15 @@ import {
|
||||
TypographyModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService, BiometricsService, BiometricStateService } from "@bitwarden/key-management";
|
||||
import {
|
||||
KeyService,
|
||||
BiometricsService,
|
||||
BiometricStateService,
|
||||
BiometricsStatus,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors";
|
||||
import { BrowserApi } from "../../../platform/browser/browser-api";
|
||||
import { enableAccountSwitching } from "../../../platform/flags";
|
||||
import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils";
|
||||
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
|
||||
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
|
||||
@@ -99,9 +104,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
availableVaultTimeoutActions: VaultTimeoutAction[] = [];
|
||||
vaultTimeoutOptions: VaultTimeoutOption[] = [];
|
||||
hasVaultTimeoutPolicy = false;
|
||||
supportsBiometric: boolean;
|
||||
biometricUnavailabilityReason: string;
|
||||
showChangeMasterPass = true;
|
||||
accountSwitcherEnabled = false;
|
||||
|
||||
form = this.formBuilder.group({
|
||||
vaultTimeout: [null as VaultTimeout | null],
|
||||
@@ -134,9 +138,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
private biometricStateService: BiometricStateService,
|
||||
private toastService: ToastService,
|
||||
private biometricsService: BiometricsService,
|
||||
) {
|
||||
this.accountSwitcherEnabled = enableAccountSwitching();
|
||||
}
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
const hasMasterPassword = await this.userVerificationService.hasMasterPassword();
|
||||
@@ -199,7 +201,40 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
};
|
||||
this.form.patchValue(initialValues, { emitEvent: false });
|
||||
|
||||
this.supportsBiometric = await this.biometricsService.supportsBiometric();
|
||||
timer(0, 1000)
|
||||
.pipe(
|
||||
switchMap(async () => {
|
||||
const status = await this.biometricsService.getBiometricsStatusForUser(activeAccount.id);
|
||||
const biometricSettingAvailable =
|
||||
(status !== BiometricsStatus.DesktopDisconnected &&
|
||||
status !== BiometricsStatus.NotEnabledInConnectedDesktopApp) ||
|
||||
(await this.vaultTimeoutSettingsService.isBiometricLockSet());
|
||||
if (!biometricSettingAvailable) {
|
||||
this.form.controls.biometric.disable({ emitEvent: false });
|
||||
} else {
|
||||
this.form.controls.biometric.enable({ emitEvent: false });
|
||||
}
|
||||
|
||||
if (status === BiometricsStatus.DesktopDisconnected && !biometricSettingAvailable) {
|
||||
this.biometricUnavailabilityReason = this.i18nService.t(
|
||||
"biometricsStatusHelptextDesktopDisconnected",
|
||||
);
|
||||
} else if (
|
||||
status === BiometricsStatus.NotEnabledInConnectedDesktopApp &&
|
||||
!biometricSettingAvailable
|
||||
) {
|
||||
this.biometricUnavailabilityReason = this.i18nService.t(
|
||||
"biometricsStatusHelptextNotEnabledInDesktop",
|
||||
activeAccount.email,
|
||||
);
|
||||
} else {
|
||||
this.biometricUnavailabilityReason = "";
|
||||
}
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.showChangeMasterPass = await this.userVerificationService.hasMasterPassword();
|
||||
|
||||
this.form.controls.vaultTimeout.valueChanges
|
||||
@@ -399,7 +434,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
async updateBiometric(enabled: boolean) {
|
||||
if (enabled && this.supportsBiometric) {
|
||||
if (enabled) {
|
||||
let granted;
|
||||
try {
|
||||
granted = await BrowserApi.requestPermission({ permissions: ["nativeMessaging"] });
|
||||
@@ -471,7 +506,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
|
||||
|
||||
const biometricsPromise = async () => {
|
||||
try {
|
||||
const result = await this.biometricsService.authenticateBiometric();
|
||||
const result = await this.biometricsService.authenticateWithBiometrics();
|
||||
|
||||
// prevent duplicate dialog
|
||||
biometricsResponseReceived = true;
|
||||
|
||||
@@ -1923,7 +1923,17 @@ describe("OverlayBackground", () => {
|
||||
|
||||
it("returns true if the overlay login ciphers are populated", async () => {
|
||||
overlayBackground["inlineMenuCiphers"] = new Map([
|
||||
["inline-menu-cipher-0", mock<CipherView>({ type: CipherType.Login })],
|
||||
[
|
||||
"inline-menu-cipher-0",
|
||||
mock<CipherView>({
|
||||
type: CipherType.Login,
|
||||
login: {
|
||||
username: "username1",
|
||||
password: "password1",
|
||||
uri: "https://example.com",
|
||||
},
|
||||
}),
|
||||
],
|
||||
]);
|
||||
await overlayBackground["getInlineMenuCipherData"]();
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ import { InlineMenuFormFieldData } from "../services/abstractions/autofill-overl
|
||||
import { AutofillService, PageDetail } from "../services/abstractions/autofill.service";
|
||||
import { InlineMenuFieldQualificationService } from "../services/abstractions/inline-menu-field-qualifications.service";
|
||||
import {
|
||||
areKeyValuesNull,
|
||||
generateDomainMatchPatterns,
|
||||
generateRandomChars,
|
||||
isInvalidResponseStatusCode,
|
||||
@@ -556,6 +557,28 @@ export class OverlayBackground implements OverlayBackgroundInterface {
|
||||
|
||||
for (let cipherIndex = 0; cipherIndex < inlineMenuCiphersArray.length; cipherIndex++) {
|
||||
const [inlineMenuCipherId, cipher] = inlineMenuCiphersArray[cipherIndex];
|
||||
|
||||
switch (cipher.type) {
|
||||
case CipherType.Card:
|
||||
if (areKeyValuesNull(cipher.card)) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
|
||||
case CipherType.Identity:
|
||||
if (areKeyValuesNull(cipher.identity)) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
|
||||
case CipherType.Login:
|
||||
if (
|
||||
areKeyValuesNull(cipher.login, ["username", "password", "totp", "fido2Credentials"])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!this.focusedFieldMatchesFillType(cipher.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -544,3 +544,20 @@ export const specialCharacterToKeyMap: Record<string, string> = {
|
||||
"?": "questionCharacterDescriptor",
|
||||
"/": "forwardSlashCharacterDescriptor",
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if all the values corresponding to the specified keys in an object are null.
|
||||
* If no keys are specified, checks all keys in the object.
|
||||
*
|
||||
* @param obj - The object to check.
|
||||
* @param keys - An optional array of keys to check in the object. Defaults to all keys.
|
||||
* @returns Returns true if all values for the specified keys (or all keys if none are provided) are null; otherwise, false.
|
||||
*/
|
||||
export function areKeyValuesNull<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
keys?: Array<keyof T>,
|
||||
): boolean {
|
||||
const keysToCheck = keys && keys.length > 0 ? keys : (Object.keys(obj) as Array<keyof T>);
|
||||
|
||||
return keysToCheck.every((key) => obj[key] == null);
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ import {
|
||||
BiometricStateService,
|
||||
BiometricsService,
|
||||
DefaultBiometricStateService,
|
||||
DefaultKeyService,
|
||||
DefaultKdfConfigService,
|
||||
KdfConfigService,
|
||||
KeyService as KeyServiceAbstraction,
|
||||
@@ -241,7 +242,6 @@ import AutofillService from "../autofill/services/autofill.service";
|
||||
import { InlineMenuFieldQualificationService } from "../autofill/services/inline-menu-field-qualification.service";
|
||||
import { SafariApp } from "../browser/safariApp";
|
||||
import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service";
|
||||
import { BrowserKeyService } from "../key-management/browser-key.service";
|
||||
import { BrowserApi } from "../platform/browser/browser-api";
|
||||
import { flagEnabled } from "../platform/flags";
|
||||
import { UpdateBadge } from "../platform/listeners/update-badge";
|
||||
@@ -416,6 +416,7 @@ export default class MainBackground {
|
||||
await this.refreshMenu(true);
|
||||
if (this.systemService != null) {
|
||||
await this.systemService.clearPendingClipboard();
|
||||
await this.biometricsService.setShouldAutopromptNow(false);
|
||||
await this.processReloadService.startProcessReload(this.authService);
|
||||
}
|
||||
};
|
||||
@@ -633,6 +634,7 @@ export default class MainBackground {
|
||||
|
||||
this.biometricsService = new BackgroundBrowserBiometricsService(
|
||||
runtimeNativeMessagingBackground,
|
||||
this.logService,
|
||||
);
|
||||
|
||||
this.kdfConfigService = new DefaultKdfConfigService(this.stateProvider);
|
||||
@@ -649,7 +651,7 @@ export default class MainBackground {
|
||||
this.stateService,
|
||||
);
|
||||
|
||||
this.keyService = new BrowserKeyService(
|
||||
this.keyService = new DefaultKeyService(
|
||||
this.pinService,
|
||||
this.masterPasswordService,
|
||||
this.keyGenerationService,
|
||||
@@ -660,8 +662,6 @@ export default class MainBackground {
|
||||
this.stateService,
|
||||
this.accountService,
|
||||
this.stateProvider,
|
||||
this.biometricStateService,
|
||||
this.biometricsService,
|
||||
this.kdfConfigService,
|
||||
);
|
||||
|
||||
@@ -857,10 +857,8 @@ export default class MainBackground {
|
||||
this.userVerificationApiService,
|
||||
this.userDecryptionOptionsService,
|
||||
this.pinService,
|
||||
this.logService,
|
||||
this.vaultTimeoutSettingsService,
|
||||
this.platformUtilsService,
|
||||
this.kdfConfigService,
|
||||
this.biometricsService,
|
||||
);
|
||||
|
||||
this.vaultFilterService = new VaultFilterService(
|
||||
@@ -890,6 +888,7 @@ export default class MainBackground {
|
||||
this.stateEventRunnerService,
|
||||
this.taskSchedulerService,
|
||||
this.logService,
|
||||
this.biometricsService,
|
||||
lockedCallback,
|
||||
logoutCallback,
|
||||
);
|
||||
@@ -1081,6 +1080,7 @@ export default class MainBackground {
|
||||
this.vaultTimeoutSettingsService,
|
||||
this.biometricStateService,
|
||||
this.accountService,
|
||||
this.logService,
|
||||
);
|
||||
|
||||
// Other fields
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
import { delay, filter, firstValueFrom, from, map, race, timer } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
@@ -14,18 +13,19 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService, BiometricStateService } from "@bitwarden/key-management";
|
||||
import { KeyService, BiometricStateService, BiometricsCommands } from "@bitwarden/key-management";
|
||||
|
||||
import { BrowserApi } from "../platform/browser/browser-api";
|
||||
|
||||
import RuntimeBackground from "./runtime.background";
|
||||
|
||||
const MessageValidTimeout = 10 * 1000;
|
||||
const MessageNoResponseTimeout = 60 * 1000;
|
||||
const HashAlgorithmForEncryption = "sha1";
|
||||
|
||||
type Message = {
|
||||
command: string;
|
||||
messageId?: number;
|
||||
|
||||
// Filled in by this service
|
||||
userId?: string;
|
||||
@@ -43,6 +43,7 @@ type OuterMessage = {
|
||||
type ReceiveMessage = {
|
||||
timestamp: number;
|
||||
command: string;
|
||||
messageId: number;
|
||||
response?: any;
|
||||
|
||||
// Unlock key
|
||||
@@ -53,19 +54,23 @@ type ReceiveMessage = {
|
||||
type ReceiveMessageOuter = {
|
||||
command: string;
|
||||
appId: string;
|
||||
messageId?: number;
|
||||
|
||||
// Should only have one of these.
|
||||
message?: EncString;
|
||||
sharedSecret?: string;
|
||||
};
|
||||
|
||||
type Callback = {
|
||||
resolver: any;
|
||||
rejecter: any;
|
||||
};
|
||||
|
||||
export class NativeMessagingBackground {
|
||||
private connected = false;
|
||||
connected = false;
|
||||
private connecting: boolean;
|
||||
private port: browser.runtime.Port | chrome.runtime.Port;
|
||||
|
||||
private resolver: any = null;
|
||||
private rejecter: any = null;
|
||||
private privateKey: Uint8Array = null;
|
||||
private publicKey: Uint8Array = null;
|
||||
private secureSetupResolve: any = null;
|
||||
@@ -73,6 +78,11 @@ export class NativeMessagingBackground {
|
||||
private appId: string;
|
||||
private validatingFingerprint: boolean;
|
||||
|
||||
private messageId = 0;
|
||||
private callbacks = new Map<number, Callback>();
|
||||
|
||||
isConnectedToOutdatedDesktopClient = true;
|
||||
|
||||
constructor(
|
||||
private keyService: KeyService,
|
||||
private encryptService: EncryptService,
|
||||
@@ -97,6 +107,7 @@ export class NativeMessagingBackground {
|
||||
}
|
||||
|
||||
async connect() {
|
||||
this.logService.info("[Native Messaging IPC] Connecting to Bitwarden Desktop app...");
|
||||
this.appId = await this.appIdService.getAppId();
|
||||
await this.biometricStateService.setFingerprintValidated(false);
|
||||
|
||||
@@ -106,6 +117,9 @@ export class NativeMessagingBackground {
|
||||
this.connecting = true;
|
||||
|
||||
const connectedCallback = () => {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Connection to Bitwarden Desktop app established!",
|
||||
);
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
resolve();
|
||||
@@ -123,11 +137,17 @@ export class NativeMessagingBackground {
|
||||
connectedCallback();
|
||||
break;
|
||||
case "disconnected":
|
||||
this.logService.info("[Native Messaging IPC] Disconnected from Bitwarden Desktop app.");
|
||||
if (this.connecting) {
|
||||
reject(new Error("startDesktop"));
|
||||
}
|
||||
this.connected = false;
|
||||
this.port.disconnect();
|
||||
// reject all
|
||||
for (const callback of this.callbacks.values()) {
|
||||
callback.rejecter("disconnected");
|
||||
}
|
||||
this.callbacks.clear();
|
||||
break;
|
||||
case "setupEncryption": {
|
||||
// Ignore since it belongs to another device
|
||||
@@ -147,6 +167,16 @@ export class NativeMessagingBackground {
|
||||
await this.biometricStateService.setFingerprintValidated(true);
|
||||
}
|
||||
this.sharedSecret = new SymmetricCryptoKey(decrypted);
|
||||
this.logService.info("[Native Messaging IPC] Secure channel established");
|
||||
|
||||
if ("messageId" in message) {
|
||||
this.logService.info("[Native Messaging IPC] Non-legacy desktop client");
|
||||
this.isConnectedToOutdatedDesktopClient = false;
|
||||
} else {
|
||||
this.logService.info("[Native Messaging IPC] Legacy desktop client");
|
||||
this.isConnectedToOutdatedDesktopClient = true;
|
||||
}
|
||||
|
||||
this.secureSetupResolve();
|
||||
break;
|
||||
}
|
||||
@@ -155,17 +185,25 @@ export class NativeMessagingBackground {
|
||||
if (message.appId !== this.appId) {
|
||||
return;
|
||||
}
|
||||
this.logService.warning(
|
||||
"[Native Messaging IPC] Secure channel encountered an error; disconnecting and wiping keys...",
|
||||
);
|
||||
|
||||
this.sharedSecret = null;
|
||||
this.privateKey = null;
|
||||
this.connected = false;
|
||||
|
||||
this.rejecter({
|
||||
message: "invalidateEncryption",
|
||||
});
|
||||
if (this.callbacks.has(message.messageId)) {
|
||||
this.callbacks.get(message.messageId).rejecter({
|
||||
message: "invalidateEncryption",
|
||||
});
|
||||
}
|
||||
return;
|
||||
case "verifyFingerprint": {
|
||||
if (this.sharedSecret == null) {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Desktop app requested trust verification by fingerprint.",
|
||||
);
|
||||
this.validatingFingerprint = true;
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
@@ -174,9 +212,11 @@ export class NativeMessagingBackground {
|
||||
break;
|
||||
}
|
||||
case "wrongUserId":
|
||||
this.rejecter({
|
||||
message: "wrongUserId",
|
||||
});
|
||||
if (this.callbacks.has(message.messageId)) {
|
||||
this.callbacks.get(message.messageId).rejecter({
|
||||
message: "wrongUserId",
|
||||
});
|
||||
}
|
||||
return;
|
||||
default:
|
||||
// Ignore since it belongs to another device
|
||||
@@ -210,6 +250,60 @@ export class NativeMessagingBackground {
|
||||
});
|
||||
}
|
||||
|
||||
async callCommand(message: Message): Promise<any> {
|
||||
const messageId = this.messageId++;
|
||||
|
||||
if (
|
||||
message.command == BiometricsCommands.Unlock ||
|
||||
message.command == BiometricsCommands.IsAvailable
|
||||
) {
|
||||
// TODO remove after 2025.01
|
||||
// wait until there is no other callbacks, or timeout
|
||||
const call = await firstValueFrom(
|
||||
race(
|
||||
from([false]).pipe(delay(5000)),
|
||||
timer(0, 100).pipe(
|
||||
filter(() => this.callbacks.size === 0),
|
||||
map(() => true),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!call) {
|
||||
this.logService.info(
|
||||
`[Native Messaging IPC] Message of type ${message.command} did not get a response before timing out`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const callback = new Promise((resolver, rejecter) => {
|
||||
this.callbacks.set(messageId, { resolver, rejecter });
|
||||
});
|
||||
message.messageId = messageId;
|
||||
try {
|
||||
await this.send(message);
|
||||
} catch (e) {
|
||||
this.logService.info(
|
||||
`[Native Messaging IPC] Error sending message of type ${message.command} to Bitwarden Desktop app. Error: ${e}`,
|
||||
);
|
||||
const callback = this.callbacks.get(messageId);
|
||||
this.callbacks.delete(messageId);
|
||||
callback.rejecter("errorConnecting");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.callbacks.has(messageId)) {
|
||||
this.logService.info("[Native Messaging IPC] Message timed out and received no response");
|
||||
this.callbacks.get(messageId).rejecter({
|
||||
message: "timeout",
|
||||
});
|
||||
this.callbacks.delete(messageId);
|
||||
}
|
||||
}, MessageNoResponseTimeout);
|
||||
|
||||
return callback;
|
||||
}
|
||||
|
||||
async send(message: Message) {
|
||||
if (!this.connected) {
|
||||
await this.connect();
|
||||
@@ -233,20 +327,7 @@ export class NativeMessagingBackground {
|
||||
return await this.encryptService.encrypt(JSON.stringify(message), this.sharedSecret);
|
||||
}
|
||||
|
||||
getResponse(): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.resolver = function (response: any) {
|
||||
resolve(response);
|
||||
};
|
||||
this.rejecter = function (resp: any) {
|
||||
reject({
|
||||
message: resp,
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private postMessage(message: OuterMessage) {
|
||||
private postMessage(message: OuterMessage, messageId?: number) {
|
||||
// Wrap in try-catch to when the port disconnected without triggering `onDisconnect`.
|
||||
try {
|
||||
const msg: any = message;
|
||||
@@ -262,13 +343,17 @@ export class NativeMessagingBackground {
|
||||
}
|
||||
this.port.postMessage(msg);
|
||||
} catch (e) {
|
||||
this.logService.error("NativeMessaging port disconnected, disconnecting.");
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Disconnected from Bitwarden Desktop app because of the native port disconnecting.",
|
||||
);
|
||||
|
||||
this.sharedSecret = null;
|
||||
this.privateKey = null;
|
||||
this.connected = false;
|
||||
|
||||
this.rejecter("invalidateEncryption");
|
||||
if (this.callbacks.has(messageId)) {
|
||||
this.callbacks.get(messageId).rejecter("invalidateEncryption");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,90 +370,30 @@ export class NativeMessagingBackground {
|
||||
}
|
||||
|
||||
if (Math.abs(message.timestamp - Date.now()) > MessageValidTimeout) {
|
||||
this.logService.error("NativeMessage is to old, ignoring.");
|
||||
this.logService.info("[Native Messaging IPC] Received an old native message, ignoring...");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.command) {
|
||||
case "biometricUnlock": {
|
||||
if (
|
||||
["not available", "not enabled", "not supported", "not unlocked", "canceled"].includes(
|
||||
message.response,
|
||||
)
|
||||
) {
|
||||
this.rejecter(message.response);
|
||||
return;
|
||||
}
|
||||
const messageId = message.messageId;
|
||||
|
||||
// Check for initial setup of biometric unlock
|
||||
const enabled = await firstValueFrom(this.biometricStateService.biometricUnlockEnabled$);
|
||||
if (enabled === null || enabled === false) {
|
||||
if (message.response === "unlocked") {
|
||||
await this.biometricStateService.setBiometricUnlockEnabled(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Ignore unlock if already unlocked
|
||||
if ((await this.authService.getAuthStatus()) === AuthenticationStatus.Unlocked) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (message.response === "unlocked") {
|
||||
try {
|
||||
if (message.userKeyB64) {
|
||||
const userKey = new SymmetricCryptoKey(
|
||||
Utils.fromB64ToArray(message.userKeyB64),
|
||||
) as UserKey;
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
const isUserKeyValid = await this.keyService.validateUserKey(userKey, activeUserId);
|
||||
if (isUserKeyValid) {
|
||||
await this.keyService.setUserKey(userKey, activeUserId);
|
||||
} else {
|
||||
this.logService.error("Unable to verify biometric unlocked userkey");
|
||||
await this.keyService.clearKeys(activeUserId);
|
||||
this.rejecter("userkey wrong");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
throw new Error("No key received");
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error("Unable to set key: " + e);
|
||||
this.rejecter("userkey wrong");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify key is correct by attempting to decrypt a secret
|
||||
try {
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
await this.keyService.getFingerprint(userId);
|
||||
} catch (e) {
|
||||
this.logService.error("Unable to verify key: " + e);
|
||||
await this.keyService.clearKeys();
|
||||
this.rejecter("userkey wrong");
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.runtimeBackground.processMessage({ command: "unlocked" });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "biometricUnlockAvailable": {
|
||||
this.resolver(message);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this.logService.error("NativeMessage, got unknown command: " + message.command);
|
||||
break;
|
||||
if (
|
||||
message.command == BiometricsCommands.Unlock ||
|
||||
message.command == BiometricsCommands.IsAvailable
|
||||
) {
|
||||
this.logService.info(
|
||||
`[Native Messaging IPC] Received legacy message of type ${message.command}`,
|
||||
);
|
||||
const messageId = this.callbacks.keys().next().value;
|
||||
const resolver = this.callbacks.get(messageId);
|
||||
this.callbacks.delete(messageId);
|
||||
resolver.resolver(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.resolver) {
|
||||
this.resolver(message);
|
||||
if (this.callbacks.has(messageId)) {
|
||||
this.callbacks.get(messageId).resolver(message);
|
||||
} else {
|
||||
this.logService.info("[Native Messaging IPC] Received message without a callback", message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,6 +409,7 @@ export class NativeMessagingBackground {
|
||||
command: "setupEncryption",
|
||||
publicKey: Utils.fromBufferToB64(publicKey),
|
||||
userId: userId,
|
||||
messageId: this.messageId++,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => (this.secureSetupResolve = resolve));
|
||||
|
||||
@@ -13,11 +13,12 @@ import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-managemen
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { MessageListener, isExternalMessage } from "@bitwarden/common/platform/messaging";
|
||||
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { BiometricsCommands } from "@bitwarden/key-management";
|
||||
|
||||
import { MessageListener, isExternalMessage } from "../../../../libs/common/src/platform/messaging";
|
||||
import {
|
||||
closeUnlockPopout,
|
||||
openSsoAuthResultPopout,
|
||||
@@ -71,8 +72,10 @@ export default class RuntimeBackground {
|
||||
sendResponse: (response: any) => void,
|
||||
) => {
|
||||
const messagesWithResponse = [
|
||||
"biometricUnlock",
|
||||
"biometricUnlockAvailable",
|
||||
BiometricsCommands.AuthenticateWithBiometrics,
|
||||
BiometricsCommands.GetBiometricsStatus,
|
||||
BiometricsCommands.UnlockWithBiometricsForUser,
|
||||
BiometricsCommands.GetBiometricsStatusForUser,
|
||||
"getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag",
|
||||
"getInlineMenuFieldQualificationFeatureFlag",
|
||||
"getInlineMenuTotpFeatureFlag",
|
||||
@@ -185,13 +188,17 @@ export default class RuntimeBackground {
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "biometricUnlock": {
|
||||
const result = await this.main.biometricsService.authenticateBiometric();
|
||||
return result;
|
||||
case BiometricsCommands.AuthenticateWithBiometrics: {
|
||||
return await this.main.biometricsService.authenticateWithBiometrics();
|
||||
}
|
||||
case "biometricUnlockAvailable": {
|
||||
const result = await this.main.biometricsService.isBiometricUnlockAvailable();
|
||||
return result;
|
||||
case BiometricsCommands.GetBiometricsStatus: {
|
||||
return await this.main.biometricsService.getBiometricsStatus();
|
||||
}
|
||||
case BiometricsCommands.UnlockWithBiometricsForUser: {
|
||||
return await this.main.biometricsService.unlockWithBiometricsForUser(msg.userId);
|
||||
}
|
||||
case BiometricsCommands.GetBiometricsStatusForUser: {
|
||||
return await this.main.biometricsService.getBiometricsStatusForUser(msg.userId);
|
||||
}
|
||||
case "getUseTreeWalkerApiForPageDetailsCollectionFeatureFlag": {
|
||||
return await this.configService.getFeatureFlag(
|
||||
|
||||
@@ -1,36 +1,136 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { NativeMessagingBackground } from "../../background/nativeMessaging.background";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsService, BiometricsCommands, BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
import { BrowserBiometricsService } from "./browser-biometrics.service";
|
||||
import { NativeMessagingBackground } from "../../background/nativeMessaging.background";
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
@Injectable()
|
||||
export class BackgroundBrowserBiometricsService extends BrowserBiometricsService {
|
||||
constructor(private nativeMessagingBackground: () => NativeMessagingBackground) {
|
||||
export class BackgroundBrowserBiometricsService extends BiometricsService {
|
||||
constructor(
|
||||
private nativeMessagingBackground: () => NativeMessagingBackground,
|
||||
private logService: LogService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
const responsePromise = this.nativeMessagingBackground().getResponse();
|
||||
await this.nativeMessagingBackground().send({ command: "biometricUnlock" });
|
||||
const response = await responsePromise;
|
||||
return response.response === "unlocked";
|
||||
async authenticateWithBiometrics(): Promise<boolean> {
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
|
||||
if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) {
|
||||
const response = await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.Unlock,
|
||||
});
|
||||
return response.response == "unlocked";
|
||||
} else {
|
||||
const response = await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.AuthenticateWithBiometrics,
|
||||
});
|
||||
return response.response;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.info("Biometric authentication failed", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async isBiometricUnlockAvailable(): Promise<boolean> {
|
||||
const responsePromise = this.nativeMessagingBackground().getResponse();
|
||||
await this.nativeMessagingBackground().send({ command: "biometricUnlockAvailable" });
|
||||
const response = await responsePromise;
|
||||
return response.response === "available";
|
||||
async getBiometricsStatus(): Promise<BiometricsStatus> {
|
||||
if (!(await BrowserApi.permissionsGranted(["nativeMessaging"]))) {
|
||||
return BiometricsStatus.NativeMessagingPermissionMissing;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
|
||||
if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) {
|
||||
const response = await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.IsAvailable,
|
||||
});
|
||||
const resp =
|
||||
response.response == "available"
|
||||
? BiometricsStatus.Available
|
||||
: BiometricsStatus.HardwareUnavailable;
|
||||
return resp;
|
||||
} else {
|
||||
const response = await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.GetBiometricsStatus,
|
||||
});
|
||||
|
||||
if (response.response) {
|
||||
return response.response;
|
||||
}
|
||||
}
|
||||
return BiometricsStatus.Available;
|
||||
} catch (e) {
|
||||
return BiometricsStatus.DesktopDisconnected;
|
||||
}
|
||||
}
|
||||
|
||||
async biometricsNeedsSetup(): Promise<boolean> {
|
||||
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
|
||||
if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) {
|
||||
const response = await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.Unlock,
|
||||
});
|
||||
if (response.response == "unlocked") {
|
||||
return response.userKeyB64;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const response = await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.UnlockWithBiometricsForUser,
|
||||
userId: userId,
|
||||
});
|
||||
if (response.response) {
|
||||
return response.userKeyB64;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.info("Biometric unlock for user failed", e);
|
||||
throw new Error("Biometric unlock failed");
|
||||
}
|
||||
}
|
||||
|
||||
async getBiometricsStatusForUser(id: UserId): Promise<BiometricsStatus> {
|
||||
try {
|
||||
await this.ensureConnected();
|
||||
|
||||
if (this.nativeMessagingBackground().isConnectedToOutdatedDesktopClient) {
|
||||
return await this.getBiometricsStatus();
|
||||
}
|
||||
|
||||
return (
|
||||
await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.GetBiometricsStatusForUser,
|
||||
userId: id,
|
||||
})
|
||||
).response;
|
||||
} catch (e) {
|
||||
return BiometricsStatus.DesktopDisconnected;
|
||||
}
|
||||
}
|
||||
|
||||
// the first time we call, this might use an outdated version of the protocol, so we drop the response
|
||||
private async ensureConnected() {
|
||||
if (!this.nativeMessagingBackground().connected) {
|
||||
await this.nativeMessagingBackground().callCommand({
|
||||
command: BiometricsCommands.IsAvailable,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getShouldAutopromptNow(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async biometricsSupportsAutoSetup(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async biometricsSetup(): Promise<void> {}
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {}
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
@Injectable()
|
||||
export abstract class BrowserBiometricsService extends BiometricsService {
|
||||
async supportsBiometric() {
|
||||
const platformInfo = await BrowserApi.getPlatformInfo();
|
||||
if (platformInfo.os === "mac" || platformInfo.os === "win" || platformInfo.os === "linux") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
abstract authenticateBiometric(): Promise<boolean>;
|
||||
abstract isBiometricUnlockAvailable(): Promise<boolean>;
|
||||
}
|
||||
@@ -1,34 +1,55 @@
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsCommands, BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
|
||||
import { BrowserBiometricsService } from "./browser-biometrics.service";
|
||||
export class ForegroundBrowserBiometricsService extends BiometricsService {
|
||||
shouldAutopromptNow = true;
|
||||
|
||||
export class ForegroundBrowserBiometricsService extends BrowserBiometricsService {
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
async authenticateWithBiometrics(): Promise<boolean> {
|
||||
const response = await BrowserApi.sendMessageWithResponse<{
|
||||
result: boolean;
|
||||
error: string;
|
||||
}>("biometricUnlock");
|
||||
}>(BiometricsCommands.AuthenticateWithBiometrics);
|
||||
if (!response.result) {
|
||||
throw response.error;
|
||||
}
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async isBiometricUnlockAvailable(): Promise<boolean> {
|
||||
async getBiometricsStatus(): Promise<BiometricsStatus> {
|
||||
const response = await BrowserApi.sendMessageWithResponse<{
|
||||
result: boolean;
|
||||
result: BiometricsStatus;
|
||||
error: string;
|
||||
}>("biometricUnlockAvailable");
|
||||
return response.result && response.result === true;
|
||||
}>(BiometricsCommands.GetBiometricsStatus);
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async biometricsNeedsSetup(): Promise<boolean> {
|
||||
return false;
|
||||
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
|
||||
const response = await BrowserApi.sendMessageWithResponse<{
|
||||
result: string;
|
||||
error: string;
|
||||
}>(BiometricsCommands.UnlockWithBiometricsForUser, { userId });
|
||||
if (!response.result) {
|
||||
return null;
|
||||
}
|
||||
return SymmetricCryptoKey.fromString(response.result) as UserKey;
|
||||
}
|
||||
|
||||
async biometricsSupportsAutoSetup(): Promise<boolean> {
|
||||
return false;
|
||||
async getBiometricsStatusForUser(id: UserId): Promise<BiometricsStatus> {
|
||||
const response = await BrowserApi.sendMessageWithResponse<{
|
||||
result: BiometricsStatus;
|
||||
error: string;
|
||||
}>(BiometricsCommands.GetBiometricsStatusForUser, { userId: id });
|
||||
return response.result;
|
||||
}
|
||||
|
||||
async biometricsSetup(): Promise<void> {}
|
||||
async getShouldAutopromptNow(): Promise<boolean> {
|
||||
return this.shouldAutopromptNow;
|
||||
}
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {
|
||||
this.shouldAutopromptNow = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { USER_KEY } from "@bitwarden/common/platform/services/key-state/user-key.state";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import {
|
||||
KdfConfigService,
|
||||
DefaultKeyService,
|
||||
BiometricsService,
|
||||
BiometricStateService,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
export class BrowserKeyService extends DefaultKeyService {
|
||||
constructor(
|
||||
pinService: PinServiceAbstraction,
|
||||
masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
keyGenerationService: KeyGenerationService,
|
||||
cryptoFunctionService: CryptoFunctionService,
|
||||
encryptService: EncryptService,
|
||||
platformUtilService: PlatformUtilsService,
|
||||
logService: LogService,
|
||||
stateService: StateService,
|
||||
accountService: AccountService,
|
||||
stateProvider: StateProvider,
|
||||
private biometricStateService: BiometricStateService,
|
||||
private biometricsService: BiometricsService,
|
||||
kdfConfigService: KdfConfigService,
|
||||
) {
|
||||
super(
|
||||
pinService,
|
||||
masterPasswordService,
|
||||
keyGenerationService,
|
||||
cryptoFunctionService,
|
||||
encryptService,
|
||||
platformUtilService,
|
||||
logService,
|
||||
stateService,
|
||||
accountService,
|
||||
stateProvider,
|
||||
kdfConfigService,
|
||||
);
|
||||
}
|
||||
override async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: UserId): Promise<boolean> {
|
||||
if (keySuffix === KeySuffixOptions.Biometric) {
|
||||
const biometricUnlockPromise =
|
||||
userId == null
|
||||
? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
|
||||
: this.biometricStateService.getBiometricUnlockEnabled(userId);
|
||||
return await biometricUnlockPromise;
|
||||
}
|
||||
return super.hasUserKeyStored(keySuffix, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser doesn't store biometric keys, so we retrieve them from the desktop and return
|
||||
* if we successfully saved it into memory as the User Key
|
||||
* @returns the `UserKey` if the user passes a biometrics prompt, otherwise return `null`.
|
||||
*/
|
||||
protected override async getKeyFromStorage(
|
||||
keySuffix: KeySuffixOptions,
|
||||
userId?: UserId,
|
||||
): Promise<UserKey> {
|
||||
if (keySuffix === KeySuffixOptions.Biometric) {
|
||||
const biometricsResult = await this.biometricsService.authenticateBiometric();
|
||||
|
||||
if (!biometricsResult) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userKey = await firstValueFrom(this.stateProvider.getUserState$(USER_KEY, userId));
|
||||
if (userKey) {
|
||||
return userKey;
|
||||
}
|
||||
}
|
||||
|
||||
return await super.getKeyFromStorage(keySuffix, userId);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService, BiometricsService } from "@bitwarden/key-management";
|
||||
import { BiometricsDisableReason, UnlockOptions } from "@bitwarden/key-management/angular";
|
||||
import { KeyService, BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { UnlockOptions } from "@bitwarden/key-management/angular";
|
||||
|
||||
import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service";
|
||||
|
||||
@@ -121,8 +121,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
describe("getAvailableUnlockOptions$", () => {
|
||||
interface MockInputs {
|
||||
hasMasterPassword: boolean;
|
||||
osSupportsBiometric: boolean;
|
||||
biometricLockSet: boolean;
|
||||
biometricsStatusForUser: BiometricsStatus;
|
||||
hasBiometricEncryptedUserKeyStored: boolean;
|
||||
platformSupportsSecureStorage: boolean;
|
||||
pinDecryptionAvailable: boolean;
|
||||
@@ -133,8 +132,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
// MP + PIN + Biometrics available
|
||||
{
|
||||
hasMasterPassword: true,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
biometricsStatusForUser: BiometricsStatus.Available,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: true,
|
||||
@@ -148,7 +146,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -156,8 +154,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
// PIN + Biometrics available
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
biometricsStatusForUser: BiometricsStatus.Available,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: true,
|
||||
@@ -171,7 +168,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -179,8 +176,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
// Biometrics available: user key stored with no secure storage
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
biometricsStatusForUser: BiometricsStatus.Available,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
platformSupportsSecureStorage: false,
|
||||
pinDecryptionAvailable: false,
|
||||
@@ -194,7 +190,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -202,8 +198,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
// Biometrics available: no user key stored with no secure storage
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
biometricsStatusForUser: BiometricsStatus.Available,
|
||||
hasBiometricEncryptedUserKeyStored: false,
|
||||
platformSupportsSecureStorage: false,
|
||||
pinDecryptionAvailable: false,
|
||||
@@ -217,7 +212,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -225,8 +220,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
// Biometrics not available: biometric lock not set
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: false,
|
||||
biometricsStatusForUser: BiometricsStatus.UnlockNeeded,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: false,
|
||||
@@ -240,7 +234,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
|
||||
biometricsStatus: BiometricsStatus.UnlockNeeded,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -248,8 +242,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
// Biometrics not available: user key not stored
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
biometricsStatusForUser: BiometricsStatus.NotEnabledInConnectedDesktopApp,
|
||||
hasBiometricEncryptedUserKeyStored: false,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: false,
|
||||
@@ -263,7 +256,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
|
||||
biometricsStatus: BiometricsStatus.NotEnabledInConnectedDesktopApp,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -271,8 +264,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
// Biometrics not available: OS doesn't support
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: false,
|
||||
biometricLockSet: true,
|
||||
biometricsStatusForUser: BiometricsStatus.HardwareUnavailable,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: false,
|
||||
@@ -286,7 +278,7 @@ describe("ExtensionLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.NotSupportedOnOperatingSystem,
|
||||
biometricsStatus: BiometricsStatus.HardwareUnavailable,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -304,8 +296,12 @@ describe("ExtensionLockComponentService", () => {
|
||||
);
|
||||
|
||||
// Biometrics
|
||||
biometricsService.supportsBiometric.mockResolvedValue(mockInputs.osSupportsBiometric);
|
||||
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(mockInputs.biometricLockSet);
|
||||
biometricsService.getBiometricsStatusForUser.mockResolvedValue(
|
||||
mockInputs.biometricsStatusForUser,
|
||||
);
|
||||
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(
|
||||
mockInputs.hasBiometricEncryptedUserKeyStored,
|
||||
);
|
||||
keyService.hasUserKeyStored.mockResolvedValue(mockInputs.hasBiometricEncryptedUserKeyStored);
|
||||
platformUtilsService.supportsSecureStorage.mockReturnValue(
|
||||
mockInputs.platformSupportsSecureStorage,
|
||||
|
||||
@@ -7,27 +7,17 @@ import {
|
||||
PinServiceAbstraction,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService, BiometricsService } from "@bitwarden/key-management";
|
||||
import {
|
||||
LockComponentService,
|
||||
BiometricsDisableReason,
|
||||
UnlockOptions,
|
||||
} from "@bitwarden/key-management/angular";
|
||||
import { BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { LockComponentService, UnlockOptions } from "@bitwarden/key-management/angular";
|
||||
|
||||
import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors";
|
||||
import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service";
|
||||
|
||||
export class ExtensionLockComponentService implements LockComponentService {
|
||||
private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction);
|
||||
private readonly platformUtilsService = inject(PlatformUtilsService);
|
||||
private readonly biometricsService = inject(BiometricsService);
|
||||
private readonly pinService = inject(PinServiceAbstraction);
|
||||
private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
|
||||
private readonly keyService = inject(KeyService);
|
||||
private readonly routerService = inject(BrowserRouterService);
|
||||
|
||||
getPreviousUrl(): string | null {
|
||||
@@ -52,67 +42,28 @@ export class ExtensionLockComponentService implements LockComponentService {
|
||||
return "unlockWithBiometrics";
|
||||
}
|
||||
|
||||
private async isBiometricLockSet(userId: UserId): Promise<boolean> {
|
||||
const biometricLockSet = await this.vaultTimeoutSettingsService.isBiometricLockSet(userId);
|
||||
const hasBiometricEncryptedUserKeyStored = await this.keyService.hasUserKeyStored(
|
||||
KeySuffixOptions.Biometric,
|
||||
userId,
|
||||
);
|
||||
const platformSupportsSecureStorage = this.platformUtilsService.supportsSecureStorage();
|
||||
|
||||
return (
|
||||
biometricLockSet && (hasBiometricEncryptedUserKeyStored || !platformSupportsSecureStorage)
|
||||
);
|
||||
}
|
||||
|
||||
private getBiometricsDisabledReason(
|
||||
osSupportsBiometric: boolean,
|
||||
biometricLockSet: boolean,
|
||||
): BiometricsDisableReason | null {
|
||||
if (!osSupportsBiometric) {
|
||||
return BiometricsDisableReason.NotSupportedOnOperatingSystem;
|
||||
} else if (!biometricLockSet) {
|
||||
return BiometricsDisableReason.EncryptedKeysUnavailable;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions> {
|
||||
return combineLatest([
|
||||
// Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to
|
||||
defer(() => this.biometricsService.supportsBiometric()),
|
||||
defer(() => this.isBiometricLockSet(userId)),
|
||||
defer(async () => await this.biometricsService.getBiometricsStatusForUser(userId)),
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
defer(() => this.pinService.isPinDecryptionAvailable(userId)),
|
||||
]).pipe(
|
||||
map(
|
||||
([
|
||||
supportsBiometric,
|
||||
isBiometricsLockSet,
|
||||
userDecryptionOptions,
|
||||
pinDecryptionAvailable,
|
||||
]) => {
|
||||
const disableReason = this.getBiometricsDisabledReason(
|
||||
supportsBiometric,
|
||||
isBiometricsLockSet,
|
||||
);
|
||||
|
||||
const unlockOpts: UnlockOptions = {
|
||||
masterPassword: {
|
||||
enabled: userDecryptionOptions.hasMasterPassword,
|
||||
},
|
||||
pin: {
|
||||
enabled: pinDecryptionAvailable,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: supportsBiometric && isBiometricsLockSet,
|
||||
disableReason: disableReason,
|
||||
},
|
||||
};
|
||||
return unlockOpts;
|
||||
},
|
||||
),
|
||||
map(([biometricsStatus, userDecryptionOptions, pinDecryptionAvailable]) => {
|
||||
const unlockOpts: UnlockOptions = {
|
||||
masterPassword: {
|
||||
enabled: userDecryptionOptions.hasMasterPassword,
|
||||
},
|
||||
pin: {
|
||||
enabled: pinDecryptionAvailable,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: biometricsStatus === BiometricsStatus.Available,
|
||||
biometricsStatus: biometricsStatus,
|
||||
},
|
||||
};
|
||||
return unlockOpts;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@bitwarden/angular/auth/guards";
|
||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||
import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh-swap";
|
||||
import { twofactorRefactorSwap } from "@bitwarden/angular/utils/two-factor-component-refactor-route-swap";
|
||||
import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards";
|
||||
import {
|
||||
AnonLayoutWrapperComponent,
|
||||
@@ -49,7 +50,6 @@ import {
|
||||
VaultIcons,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { twofactorRefactorSwap } from "../../../../libs/angular/src/utils/two-factor-component-refactor-route-swap";
|
||||
import { fido2AuthGuard } from "../auth/guards/fido2-auth.guard";
|
||||
import { AccountSwitcherComponent } from "../auth/popup/account-switching/account-switcher.component";
|
||||
import { EnvironmentComponent } from "../auth/popup/environment.component";
|
||||
|
||||
@@ -111,8 +111,8 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac
|
||||
import {
|
||||
KdfConfigService,
|
||||
KeyService,
|
||||
BiometricStateService,
|
||||
BiometricsService,
|
||||
DefaultKeyService,
|
||||
} from "@bitwarden/key-management";
|
||||
import { LockComponentService } from "@bitwarden/key-management/angular";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
@@ -126,7 +126,6 @@ import { AutofillService as AutofillServiceAbstraction } from "../../autofill/se
|
||||
import AutofillService from "../../autofill/services/autofill.service";
|
||||
import { InlineMenuFieldQualificationService } from "../../autofill/services/inline-menu-field-qualification.service";
|
||||
import { ForegroundBrowserBiometricsService } from "../../key-management/biometrics/foreground-browser-biometrics";
|
||||
import { BrowserKeyService } from "../../key-management/browser-key.service";
|
||||
import { ExtensionLockComponentService } from "../../key-management/lock/services/extension-lock-component.service";
|
||||
import { BrowserApi } from "../../platform/browser/browser-api";
|
||||
import { runInsideAngular } from "../../platform/browser/run-inside-angular.operator";
|
||||
@@ -232,11 +231,9 @@ const safeProviders: SafeProvider[] = [
|
||||
stateService: StateService,
|
||||
accountService: AccountServiceAbstraction,
|
||||
stateProvider: StateProvider,
|
||||
biometricStateService: BiometricStateService,
|
||||
biometricsService: BiometricsService,
|
||||
kdfConfigService: KdfConfigService,
|
||||
) => {
|
||||
const keyService = new BrowserKeyService(
|
||||
const keyService = new DefaultKeyService(
|
||||
pinService,
|
||||
masterPasswordService,
|
||||
keyGenerationService,
|
||||
@@ -247,8 +244,6 @@ const safeProviders: SafeProvider[] = [
|
||||
stateService,
|
||||
accountService,
|
||||
stateProvider,
|
||||
biometricStateService,
|
||||
biometricsService,
|
||||
kdfConfigService,
|
||||
);
|
||||
new ContainerService(keyService, encryptService).attachToGlobal(self);
|
||||
@@ -265,8 +260,6 @@ const safeProviders: SafeProvider[] = [
|
||||
StateService,
|
||||
AccountServiceAbstraction,
|
||||
StateProvider,
|
||||
BiometricStateService,
|
||||
BiometricsService,
|
||||
KdfConfigService,
|
||||
],
|
||||
}),
|
||||
@@ -574,7 +567,7 @@ const safeProviders: SafeProvider[] = [
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SdkClientFactory,
|
||||
useFactory: (logService) =>
|
||||
useFactory: (logService: LogService) =>
|
||||
flagEnabled("sdk") ? new BrowserSdkClientFactory(logService) : new NoopSdkClientFactory(),
|
||||
deps: [LogService],
|
||||
}),
|
||||
|
||||
@@ -86,8 +86,203 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
}
|
||||
return
|
||||
case "biometricUnlock":
|
||||
case "authenticateWithBiometrics":
|
||||
let messageId = message?["messageId"] as? Int
|
||||
let laContext = LAContext()
|
||||
guard let accessControl = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .userPresence], nil) else {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "authenticateWithBiometrics",
|
||||
"response": false,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
break
|
||||
}
|
||||
laContext.evaluateAccessControl(accessControl, operation: .useKeySign, localizedReason: "authenticate") { (success, error) in
|
||||
if success {
|
||||
response.userInfo = [ SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "authenticateWithBiometrics",
|
||||
"response": true,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
]]
|
||||
} else {
|
||||
response.userInfo = [ SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "authenticateWithBiometrics",
|
||||
"response": false,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
]]
|
||||
}
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
}
|
||||
return
|
||||
case "getBiometricsStatus":
|
||||
let messageId = message?["messageId"] as? Int
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "getBiometricsStatus",
|
||||
"response": BiometricsStatus.Available.rawValue,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil);
|
||||
break
|
||||
case "unlockWithBiometricsForUser":
|
||||
let messageId = message?["messageId"] as? Int
|
||||
var error: NSError?
|
||||
let laContext = LAContext()
|
||||
|
||||
laContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
|
||||
if let e = error, e.code != kLAErrorBiometryLockout {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "biometricUnlock",
|
||||
"response": false,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
break
|
||||
}
|
||||
|
||||
guard let accessControl = SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .userPresence], nil) else {
|
||||
let messageId = message?["messageId"] as? Int
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "biometricUnlock",
|
||||
"response": false,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
break
|
||||
}
|
||||
laContext.evaluateAccessControl(accessControl, operation: .useKeySign, localizedReason: "unlock your vault") { (success, error) in
|
||||
if success {
|
||||
guard let userId = message?["userId"] as? String else {
|
||||
return
|
||||
}
|
||||
let passwordName = userId + "_user_biometric"
|
||||
var passwordLength: UInt32 = 0
|
||||
var passwordPtr: UnsafeMutableRawPointer? = nil
|
||||
|
||||
var status = SecKeychainFindGenericPassword(nil, UInt32(ServiceNameBiometric.utf8.count), ServiceNameBiometric, UInt32(passwordName.utf8.count), passwordName, &passwordLength, &passwordPtr, nil)
|
||||
if status != errSecSuccess {
|
||||
let fallbackName = "key"
|
||||
status = SecKeychainFindGenericPassword(nil, UInt32(ServiceNameBiometric.utf8.count), ServiceNameBiometric, UInt32(fallbackName.utf8.count), fallbackName, &passwordLength, &passwordPtr, nil)
|
||||
}
|
||||
|
||||
if status == errSecSuccess {
|
||||
let result = NSString(bytes: passwordPtr!, length: Int(passwordLength), encoding: String.Encoding.utf8.rawValue) as String?
|
||||
SecKeychainItemFreeContent(nil, passwordPtr)
|
||||
|
||||
response.userInfo = [ SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "biometricUnlock",
|
||||
"response": true,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"userKeyB64": result!.replacingOccurrences(of: "\"", with: ""),
|
||||
"messageId": messageId,
|
||||
],
|
||||
]]
|
||||
} else {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "biometricUnlock",
|
||||
"response": true,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
}
|
||||
return
|
||||
case "getBiometricsStatusForUser":
|
||||
let messageId = message?["messageId"] as? Int
|
||||
let laContext = LAContext()
|
||||
if !laContext.isBiometricsAvailable() {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "getBiometricsStatusForUser",
|
||||
"response": BiometricsStatus.HardwareUnavailable.rawValue,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
break
|
||||
}
|
||||
|
||||
guard let userId = message?["userId"] as? String else {
|
||||
return
|
||||
}
|
||||
let passwordName = userId + "_user_biometric"
|
||||
var passwordLength: UInt32 = 0
|
||||
var passwordPtr: UnsafeMutableRawPointer? = nil
|
||||
|
||||
var status = SecKeychainFindGenericPassword(nil, UInt32(ServiceNameBiometric.utf8.count), ServiceNameBiometric, UInt32(passwordName.utf8.count), passwordName, &passwordLength, &passwordPtr, nil)
|
||||
if status != errSecSuccess {
|
||||
let fallbackName = "key"
|
||||
status = SecKeychainFindGenericPassword(nil, UInt32(ServiceNameBiometric.utf8.count), ServiceNameBiometric, UInt32(fallbackName.utf8.count), fallbackName, &passwordLength, &passwordPtr, nil)
|
||||
}
|
||||
|
||||
if status == errSecSuccess {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "getBiometricsStatusForUser",
|
||||
"response": BiometricsStatus.Available.rawValue,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
} else {
|
||||
response.userInfo = [
|
||||
SFExtensionMessageKey: [
|
||||
"message": [
|
||||
"command": "getBiometricsStatusForUser",
|
||||
"response": BiometricsStatus.NotEnabledInConnectedDesktopApp.rawValue,
|
||||
"timestamp": Int64(NSDate().timeIntervalSince1970 * 1000),
|
||||
"messageId": messageId,
|
||||
],
|
||||
],
|
||||
]
|
||||
}
|
||||
break
|
||||
case "biometricUnlock":
|
||||
var error: NSError?
|
||||
let laContext = LAContext()
|
||||
|
||||
if(!laContext.isBiometricsAvailable()){
|
||||
@@ -115,7 +310,7 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
|
||||
]
|
||||
break
|
||||
}
|
||||
laContext.evaluateAccessControl(accessControl, operation: .useKeySign, localizedReason: "Bitwarden Safari Extension") { (success, error) in
|
||||
laContext.evaluateAccessControl(accessControl, operation: .useKeySign, localizedReason: "Biometric Unlock") { (success, error) in
|
||||
if success {
|
||||
guard let userId = message?["userId"] as? String else {
|
||||
return
|
||||
@@ -157,7 +352,6 @@ class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling {
|
||||
|
||||
context.completeRequest(returningItems: [response], completionHandler: nil)
|
||||
}
|
||||
|
||||
return
|
||||
case "biometricUnlockAvailable":
|
||||
let laContext = LAContext()
|
||||
@@ -228,3 +422,15 @@ class DownloadFileMessage: Decodable, Encodable {
|
||||
class DownloadFileMessageBlobOptions: Decodable, Encodable {
|
||||
var type: String?
|
||||
}
|
||||
|
||||
enum BiometricsStatus : Int {
|
||||
case Available = 0
|
||||
case UnlockNeeded = 1
|
||||
case HardwareUnavailable = 2
|
||||
case AutoSetupNeeded = 3
|
||||
case ManualSetupNeeded = 4
|
||||
case PlatformUnsupported = 5
|
||||
case DesktopDisconnected = 6
|
||||
case NotEnabledLocally = 7
|
||||
case NotEnabledInConnectedDesktopApp = 8
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
size="small"
|
||||
[attr.aria-label]="'moreOptionsLabel' | i18n: cipher.name"
|
||||
[title]="'moreOptionsTitle' | i18n: cipher.name"
|
||||
[disabled]="cipher.decryptionFailure"
|
||||
[bitMenuTriggerFor]="moreOptions"
|
||||
></button>
|
||||
<bit-menu #moreOptions>
|
||||
|
||||
@@ -27,6 +27,15 @@
|
||||
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
|
||||
{{ "note" | i18n }}
|
||||
</a>
|
||||
<a
|
||||
bitMenuItem
|
||||
[routerLink]="['/add-cipher']"
|
||||
[queryParams]="buildQueryParams(cipherType.SshKey)"
|
||||
*ngIf="sshKeysEnabled"
|
||||
>
|
||||
<i class="bwi bwi-key" slot="start" aria-hidden="true"></i>
|
||||
{{ "typeSshKey" | i18n }}
|
||||
</a>
|
||||
<bit-menu-divider></bit-menu-divider>
|
||||
<button type="button" bitMenuItem (click)="openFolderDialog()">
|
||||
<i class="bwi bwi-folder" slot="start" aria-hidden="true"></i>
|
||||
|
||||
@@ -3,7 +3,9 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute, RouterLink } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
@@ -45,6 +47,7 @@ describe("NewItemDropdownV2Component", () => {
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
JslibModule,
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
ButtonModule,
|
||||
@@ -53,6 +56,8 @@ describe("NewItemDropdownV2Component", () => {
|
||||
NewItemDropdownV2Component,
|
||||
],
|
||||
providers: [
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: ConfigService, useValue: { getFeatureFlag: () => Promise.resolve(false) } },
|
||||
{ provide: DialogService, useValue: dialogServiceMock },
|
||||
{ provide: I18nService, useValue: i18nServiceMock },
|
||||
{ provide: ActivatedRoute, useValue: activatedRouteMock },
|
||||
@@ -82,7 +87,7 @@ describe("NewItemDropdownV2Component", () => {
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
|
||||
jest.spyOn(Utils, "getHostname").mockReturnValue("example.com");
|
||||
|
||||
const params = component.buildQueryParams(CipherType.Login);
|
||||
const params = await component.buildQueryParams(CipherType.Login);
|
||||
|
||||
expect(params).toEqual({
|
||||
type: CipherType.Login.toString(),
|
||||
@@ -94,14 +99,14 @@ describe("NewItemDropdownV2Component", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should build query params for a Login cipher when popped out", () => {
|
||||
it("should build query params for a Login cipher when popped out", async () => {
|
||||
component.initialValues = {
|
||||
collectionId: "777-888-999",
|
||||
} as NewItemInitialValues;
|
||||
|
||||
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true);
|
||||
|
||||
const params = component.buildQueryParams(CipherType.Login);
|
||||
const params = await component.buildQueryParams(CipherType.Login);
|
||||
|
||||
expect(params).toEqual({
|
||||
type: CipherType.Login.toString(),
|
||||
@@ -109,12 +114,12 @@ describe("NewItemDropdownV2Component", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should build query params for a secure note", () => {
|
||||
it("should build query params for a secure note", async () => {
|
||||
component.initialValues = {
|
||||
collectionId: "777-888-999",
|
||||
} as NewItemInitialValues;
|
||||
|
||||
const params = component.buildQueryParams(CipherType.SecureNote);
|
||||
const params = await component.buildQueryParams(CipherType.SecureNote);
|
||||
|
||||
expect(params).toEqual({
|
||||
type: CipherType.SecureNote.toString(),
|
||||
@@ -122,12 +127,12 @@ describe("NewItemDropdownV2Component", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should build query params for an Identity", () => {
|
||||
it("should build query params for an Identity", async () => {
|
||||
component.initialValues = {
|
||||
collectionId: "777-888-999",
|
||||
} as NewItemInitialValues;
|
||||
|
||||
const params = component.buildQueryParams(CipherType.Identity);
|
||||
const params = await component.buildQueryParams(CipherType.Identity);
|
||||
|
||||
expect(params).toEqual({
|
||||
type: CipherType.Identity.toString(),
|
||||
@@ -135,12 +140,12 @@ describe("NewItemDropdownV2Component", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should build query params for a Card", () => {
|
||||
it("should build query params for a Card", async () => {
|
||||
component.initialValues = {
|
||||
collectionId: "777-888-999",
|
||||
} as NewItemInitialValues;
|
||||
|
||||
const params = component.buildQueryParams(CipherType.Card);
|
||||
const params = await component.buildQueryParams(CipherType.Card);
|
||||
|
||||
expect(params).toEqual({
|
||||
type: CipherType.Card.toString(),
|
||||
@@ -148,12 +153,12 @@ describe("NewItemDropdownV2Component", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should build query params for a SshKey", () => {
|
||||
it("should build query params for a SshKey", async () => {
|
||||
component.initialValues = {
|
||||
collectionId: "777-888-999",
|
||||
} as NewItemInitialValues;
|
||||
|
||||
const params = component.buildQueryParams(CipherType.SshKey);
|
||||
const params = await component.buildQueryParams(CipherType.SshKey);
|
||||
|
||||
expect(params).toEqual({
|
||||
type: CipherType.SshKey.toString(),
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { RouterLink } from "@angular/router";
|
||||
import { Router, RouterLink } from "@angular/router";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
@@ -35,14 +37,20 @@ export class NewItemDropdownV2Component implements OnInit {
|
||||
*/
|
||||
@Input()
|
||||
initialValues: NewItemInitialValues;
|
||||
constructor(
|
||||
private router: Router,
|
||||
private dialogService: DialogService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
constructor(private dialogService: DialogService) {}
|
||||
sshKeysEnabled = false;
|
||||
|
||||
async ngOnInit() {
|
||||
this.sshKeysEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHKeyVaultItem);
|
||||
this.tab = await BrowserApi.getTabFromCurrentWindow();
|
||||
}
|
||||
|
||||
buildQueryParams(type: CipherType): AddEditQueryParams {
|
||||
async buildQueryParams(type: CipherType): Promise<AddEditQueryParams> {
|
||||
const poppedOut = BrowserPopupUtils.inPopout(window);
|
||||
|
||||
const loginDetails: { uri?: string; name?: string } = {};
|
||||
|
||||
@@ -18,19 +18,25 @@ import { map } from "rxjs";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
BadgeModule,
|
||||
ButtonModule,
|
||||
CompactModeService,
|
||||
DialogService,
|
||||
IconButtonModule,
|
||||
ItemModule,
|
||||
SectionComponent,
|
||||
SectionHeaderComponent,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { OrgIconDirective, PasswordRepromptService } from "@bitwarden/vault";
|
||||
import {
|
||||
DecryptionFailureDialogComponent,
|
||||
OrgIconDirective,
|
||||
PasswordRepromptService,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { BrowserApi } from "../../../../../platform/browser/browser-api";
|
||||
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
|
||||
@@ -55,6 +61,7 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options
|
||||
ItemMoreOptionsComponent,
|
||||
OrgIconDirective,
|
||||
ScrollingModule,
|
||||
DecryptionFailureDialogComponent,
|
||||
],
|
||||
selector: "app-vault-list-items-container",
|
||||
templateUrl: "vault-list-items-container.component.html",
|
||||
@@ -158,6 +165,7 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
|
||||
private cipherService: CipherService,
|
||||
private router: Router,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private dialogService: DialogService,
|
||||
) {}
|
||||
|
||||
async ngAfterViewInit() {
|
||||
@@ -209,6 +217,13 @@ export class VaultListItemsContainerComponent implements AfterViewInit {
|
||||
this.viewCipherTimeout = window.setTimeout(
|
||||
async () => {
|
||||
try {
|
||||
if (cipher.decryptionFailure) {
|
||||
DecryptionFailureDialogComponent.open(this.dialogService, {
|
||||
cipherIds: [cipher.id as CipherId],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||
if (!repromptPassed) {
|
||||
return;
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { ScrollingModule } from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Component, DestroyRef, OnDestroy, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { RouterLink } from "@angular/router";
|
||||
import { combineLatest, Observable, shareReplay, switchMap } from "rxjs";
|
||||
import { filter, map, take } from "rxjs/operators";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components";
|
||||
import { VaultIcons } from "@bitwarden/vault";
|
||||
import { ButtonModule, DialogService, Icons, NoItemsModule } from "@bitwarden/components";
|
||||
import { DecryptionFailureDialogComponent, VaultIcons } from "@bitwarden/vault";
|
||||
|
||||
import { CurrentAccountComponent } from "../../../../auth/popup/account-switching/current-account.component";
|
||||
import { PopOutComponent } from "../../../../platform/popup/components/pop-out.component";
|
||||
@@ -52,6 +54,7 @@ enum VaultState {
|
||||
NewItemDropdownV2Component,
|
||||
ScrollingModule,
|
||||
VaultHeaderV2Component,
|
||||
DecryptionFailureDialogComponent,
|
||||
],
|
||||
providers: [VaultUiOnboardingService],
|
||||
})
|
||||
@@ -89,6 +92,9 @@ export class VaultV2Component implements OnInit, OnDestroy {
|
||||
private vaultPopupItemsService: VaultPopupItemsService,
|
||||
private vaultPopupListFiltersService: VaultPopupListFiltersService,
|
||||
private vaultUiOnboardingService: VaultUiOnboardingService,
|
||||
private destroyRef: DestroyRef,
|
||||
private cipherService: CipherService,
|
||||
private dialogService: DialogService,
|
||||
) {
|
||||
combineLatest([
|
||||
this.vaultPopupItemsService.emptyVault$,
|
||||
@@ -116,6 +122,19 @@ export class VaultV2Component implements OnInit, OnDestroy {
|
||||
|
||||
async ngOnInit() {
|
||||
await this.vaultUiOnboardingService.showOnboardingDialog();
|
||||
|
||||
this.cipherService.failedToDecryptCiphers$
|
||||
.pipe(
|
||||
map((ciphers) => ciphers.filter((c) => !c.isDeleted)),
|
||||
filter((ciphers) => ciphers.length > 0),
|
||||
take(1),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((ciphers) => {
|
||||
DecryptionFailureDialogComponent.open(this.dialogService, {
|
||||
cipherIds: ciphers.map((c) => c.id as CipherId),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {}
|
||||
|
||||
@@ -58,6 +58,7 @@ describe("VaultPopupItemsService", () => {
|
||||
cipherServiceMock.getAllDecrypted.mockResolvedValue(cipherList);
|
||||
cipherServiceMock.ciphers$ = new BehaviorSubject(null);
|
||||
cipherServiceMock.localData$ = new BehaviorSubject(null);
|
||||
cipherServiceMock.failedToDecryptCiphers$ = new BehaviorSubject([]);
|
||||
searchService.searchCiphers.mockImplementation(async (_, __, ciphers) => ciphers);
|
||||
cipherServiceMock.filterCiphersForUrl.mockImplementation(async (ciphers) =>
|
||||
ciphers.filter((c) => ["0", "1"].includes(c.id)),
|
||||
@@ -294,21 +295,6 @@ describe("VaultPopupItemsService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should sort by last used then by name by default", (done) => {
|
||||
service.remainingCiphers$.subscribe(() => {
|
||||
expect(cipherServiceMock.getLocaleSortingFunction).toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT sort by last used then by name when search text is applied", (done) => {
|
||||
service.applyFilter("Login");
|
||||
service.remainingCiphers$.subscribe(() => {
|
||||
expect(cipherServiceMock.getLocaleSortingFunction).not.toHaveBeenCalled();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter remainingCiphers$ down to search term", (done) => {
|
||||
const cipherList = Object.values(allCiphers);
|
||||
const searchText = "Login";
|
||||
|
||||
@@ -90,6 +90,8 @@ export class VaultPopupItemsService {
|
||||
tap(() => this._ciphersLoading$.next()),
|
||||
waitUntilSync(this.syncService),
|
||||
switchMap(() => Utils.asyncToObservable(() => this.cipherService.getAllDecrypted())),
|
||||
withLatestFrom(this.cipherService.failedToDecryptCiphers$),
|
||||
map(([ciphers, failedToDecryptCiphers]) => [...failedToDecryptCiphers, ...ciphers]),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
@@ -190,11 +192,6 @@ export class VaultPopupItemsService {
|
||||
(cipher) => !autoFillCiphers.includes(cipher) && !favoriteCiphers.includes(cipher),
|
||||
),
|
||||
),
|
||||
withLatestFrom(this._hasSearchText$),
|
||||
map(([ciphers, hasSearchText]) =>
|
||||
// Do not sort alphabetically when there is search text, default to the search service scoring
|
||||
hasSearchText ? ciphers : ciphers.sort(this.cipherService.getLocaleSortingFunction()),
|
||||
),
|
||||
shareReplay({ refCount: false, bufferSize: 1 }),
|
||||
);
|
||||
|
||||
|
||||
@@ -27,7 +27,12 @@
|
||||
[bitMenuTriggerFor]="moreOptions"
|
||||
></button>
|
||||
<bit-menu #moreOptions>
|
||||
<button type="button" bitMenuItem (click)="restore(cipher)">
|
||||
<button
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="restore(cipher)"
|
||||
*ngIf="!cipher.decryptionFailure"
|
||||
>
|
||||
{{ "restore" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem *appCanDeleteCipher="cipher" (click)="delete(cipher)">
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Router } from "@angular/router";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import {
|
||||
@@ -19,7 +20,11 @@ import {
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { CanDeleteCipherDirective, PasswordRepromptService } from "@bitwarden/vault";
|
||||
import {
|
||||
CanDeleteCipherDirective,
|
||||
DecryptionFailureDialogComponent,
|
||||
PasswordRepromptService,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
@Component({
|
||||
selector: "app-trash-list-items-container",
|
||||
@@ -35,6 +40,7 @@ import { CanDeleteCipherDirective, PasswordRepromptService } from "@bitwarden/va
|
||||
MenuModule,
|
||||
IconButtonModule,
|
||||
TypographyModule,
|
||||
DecryptionFailureDialogComponent,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
@@ -105,6 +111,13 @@ export class TrashListItemsContainerComponent {
|
||||
}
|
||||
|
||||
async onViewCipher(cipher: CipherView) {
|
||||
if (cipher.decryptionFailure) {
|
||||
DecryptionFailureDialogComponent.open(this.dialogService, {
|
||||
cipherIds: [cipher.id as CipherId],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const repromptPassed = await this.passwordRepromptService.passwordRepromptCheck(cipher);
|
||||
if (!repromptPassed) {
|
||||
return;
|
||||
|
||||
27
apps/cli/src/key-management/cli-biometrics-service.ts
Normal file
27
apps/cli/src/key-management/cli-biometrics-service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
export class CliBiometricsService extends BiometricsService {
|
||||
async authenticateWithBiometrics(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async getBiometricsStatus(): Promise<BiometricsStatus> {
|
||||
return BiometricsStatus.PlatformUnsupported;
|
||||
}
|
||||
|
||||
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async getBiometricsStatusForUser(userId: UserId): Promise<BiometricsStatus> {
|
||||
return BiometricsStatus.PlatformUnsupported;
|
||||
}
|
||||
|
||||
async getShouldAutopromptNow(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {}
|
||||
}
|
||||
@@ -165,6 +165,7 @@ import {
|
||||
VaultExportServiceAbstraction,
|
||||
} from "@bitwarden/vault-export-core";
|
||||
|
||||
import { CliBiometricsService } from "../key-management/cli-biometrics-service";
|
||||
import { flagEnabled } from "../platform/flags";
|
||||
import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service";
|
||||
import { ConsoleLogService } from "../platform/services/console-log.service";
|
||||
@@ -693,12 +694,12 @@ export class ServiceContainer {
|
||||
this.userVerificationApiService,
|
||||
this.userDecryptionOptionsService,
|
||||
this.pinService,
|
||||
this.logService,
|
||||
this.vaultTimeoutSettingsService,
|
||||
this.platformUtilsService,
|
||||
this.kdfConfigService,
|
||||
new CliBiometricsService(),
|
||||
);
|
||||
|
||||
const biometricService = new CliBiometricsService();
|
||||
|
||||
this.vaultTimeoutService = new VaultTimeoutService(
|
||||
this.accountService,
|
||||
this.masterPasswordService,
|
||||
@@ -714,6 +715,7 @@ export class ServiceContainer {
|
||||
this.stateEventRunnerService,
|
||||
this.taskSchedulerService,
|
||||
this.logService,
|
||||
biometricService,
|
||||
lockedCallback,
|
||||
undefined,
|
||||
);
|
||||
|
||||
24
apps/desktop/desktop_native/Cargo.lock
generated
24
apps/desktop/desktop_native/Cargo.lock
generated
@@ -324,28 +324,6 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
|
||||
dependencies = [
|
||||
"async-stream-impl",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream-impl"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
@@ -920,7 +898,6 @@ dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
"argon2",
|
||||
"async-stream",
|
||||
"base64",
|
||||
"bitwarden-russh",
|
||||
"byteorder",
|
||||
@@ -939,7 +916,6 @@ dependencies = [
|
||||
"pin-project",
|
||||
"pkcs8",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
"retry",
|
||||
"rsa",
|
||||
"russh-cryptovec",
|
||||
|
||||
@@ -26,12 +26,10 @@ arboard = { version = "=3.4.1", default-features = false, features = [
|
||||
"wayland-data-control",
|
||||
] }
|
||||
argon2 = { version = "=0.5.3", features = ["zeroize"] }
|
||||
async-stream = "=0.3.6"
|
||||
base64 = "=0.22.1"
|
||||
byteorder = "=1.5.0"
|
||||
cbc = { version = "=0.1.2", features = ["alloc"] }
|
||||
homedir = "=0.3.4"
|
||||
libc = "=0.2.169"
|
||||
pin-project = "=1.1.7"
|
||||
dirs = "=5.0.1"
|
||||
futures = "=0.3.31"
|
||||
@@ -55,7 +53,6 @@ tokio-stream = { version = "=0.1.15", features = ["net"] }
|
||||
tokio-util = { version = "=0.7.12", features = ["codec"] }
|
||||
thiserror = "=1.0.69"
|
||||
typenum = "=1.17.0"
|
||||
rand_chacha = "=0.3.1"
|
||||
pkcs8 = { version = "=0.10.2", features = ["alloc", "encryption", "pem"] }
|
||||
rsa = "=0.9.6"
|
||||
ed25519 = { version = "=2.2.3", features = ["pkcs8"] }
|
||||
@@ -87,6 +84,7 @@ desktop_objc = { path = "../objc" }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
oo7 = "=0.3.3"
|
||||
libc = "=0.2.169"
|
||||
|
||||
zbus = { version = "=4.4.0", optional = true }
|
||||
zbus_polkit = { version = "=4.0.0", optional = true }
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
use rand::SeedableRng;
|
||||
use rand_chacha::ChaCha8Rng;
|
||||
use ssh_key::{Algorithm, HashAlg, LineEnding};
|
||||
|
||||
use super::importer::SshKey;
|
||||
|
||||
pub async fn generate_keypair(key_algorithm: String) -> Result<SshKey, anyhow::Error> {
|
||||
// sourced from cryptographically secure entropy source, with sources for all targets: https://docs.rs/getrandom
|
||||
// if it cannot be securely sourced, this will panic instead of leading to a weak key
|
||||
let mut rng: ChaCha8Rng = ChaCha8Rng::from_entropy();
|
||||
|
||||
let key = match key_algorithm.as_str() {
|
||||
"ed25519" => ssh_key::PrivateKey::random(&mut rng, Algorithm::Ed25519),
|
||||
"rsa2048" | "rsa3072" | "rsa4096" => {
|
||||
let bits = match key_algorithm.as_str() {
|
||||
"rsa2048" => 2048,
|
||||
"rsa3072" => 3072,
|
||||
"rsa4096" => 4096,
|
||||
_ => return Err(anyhow::anyhow!("Unsupported RSA key size")),
|
||||
};
|
||||
let rsa_keypair = ssh_key::private::RsaKeypair::random(&mut rng, bits)
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
|
||||
let private_key = ssh_key::PrivateKey::new(
|
||||
ssh_key::private::KeypairData::from(rsa_keypair),
|
||||
"".to_string(),
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
Ok(private_key)
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!("Unsupported key algorithm"));
|
||||
}
|
||||
}
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
|
||||
let private_key_openssh = key
|
||||
.to_openssh(LineEnding::LF)
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
Ok(SshKey {
|
||||
private_key: private_key_openssh.to_string(),
|
||||
public_key: key.public_key().to_string(),
|
||||
key_fingerprint: key.fingerprint(HashAlg::Sha256).to_string(),
|
||||
})
|
||||
}
|
||||
@@ -16,7 +16,6 @@ mod platform_ssh_agent;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
mod peercred_unix_listener_stream;
|
||||
|
||||
pub mod generator;
|
||||
pub mod importer;
|
||||
pub mod peerinfo;
|
||||
#[derive(Clone)]
|
||||
|
||||
1
apps/desktop/desktop_native/napi/index.d.ts
vendored
1
apps/desktop/desktop_native/napi/index.d.ts
vendored
@@ -74,7 +74,6 @@ export declare namespace sshagent {
|
||||
export function lock(agentState: SshAgentState): void
|
||||
export function importKey(encodedKey: string, password: string): SshKeyImportResult
|
||||
export function clearKeys(agentState: SshAgentState): void
|
||||
export function generateKeypair(keyAlgorithm: string): Promise<SshKey>
|
||||
export class SshAgentState { }
|
||||
}
|
||||
export declare namespace processisolations {
|
||||
|
||||
@@ -362,14 +362,6 @@ pub mod sshagent {
|
||||
.clear_keys()
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn generate_keypair(key_algorithm: String) -> napi::Result<SshKey> {
|
||||
desktop_core::ssh_agent::generator::generate_keypair(key_algorithm)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
.map(|k| k.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
|
||||
@@ -33,7 +33,11 @@ modules:
|
||||
- install bitwarden.sh /app/bin/bitwarden.sh
|
||||
sources:
|
||||
- type: dir
|
||||
only-arches: [x86_64]
|
||||
path: ../dist/linux-unpacked
|
||||
- type: dir
|
||||
only-arches: [aarch64]
|
||||
path: ../dist/linux-arm64-unpacked
|
||||
- type: script
|
||||
dest-filename: bitwarden.sh
|
||||
commands:
|
||||
|
||||
@@ -22,7 +22,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { KeySuffixOptions, ThemeType } from "@bitwarden/common/platform/enums";
|
||||
import { ThemeType } from "@bitwarden/common/platform/enums/theme-type.enum";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -32,10 +32,11 @@ import {
|
||||
VaultTimeoutStringType,
|
||||
} from "@bitwarden/common/types/vault-timeout.type";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { KeyService, BiometricsService, BiometricStateService } from "@bitwarden/key-management";
|
||||
import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
import { SetPinComponent } from "../../auth/components/set-pin.component";
|
||||
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
|
||||
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
|
||||
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
|
||||
import { NativeMessagingManifestService } from "../services/native-messaging-manifest.service";
|
||||
|
||||
@@ -54,6 +55,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
themeOptions: any[];
|
||||
clearClipboardOptions: any[];
|
||||
supportsBiometric: boolean;
|
||||
private timerId: any;
|
||||
showAlwaysShowDock = false;
|
||||
requireEnableTray = false;
|
||||
showDuckDuckGoIntegrationOption = false;
|
||||
@@ -139,7 +141,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
private userVerificationService: UserVerificationServiceAbstraction,
|
||||
private desktopSettingsService: DesktopSettingsService,
|
||||
private biometricStateService: BiometricStateService,
|
||||
private biometricsService: BiometricsService,
|
||||
private biometricsService: DesktopBiometricsService,
|
||||
private desktopAutofillSettingsService: DesktopAutofillSettingsService,
|
||||
private pinService: PinServiceAbstraction,
|
||||
private logService: LogService,
|
||||
@@ -297,7 +299,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
// Non-form values
|
||||
this.showMinToTray = this.platformUtilsService.getDevice() !== DeviceType.LinuxDesktop;
|
||||
this.showAlwaysShowDock = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
|
||||
this.supportsBiometric = await this.biometricsService.supportsBiometric();
|
||||
this.previousVaultTimeout = this.form.value.vaultTimeout;
|
||||
|
||||
this.refreshTimeoutSettings$
|
||||
@@ -360,6 +361,13 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.form.controls.enableBrowserIntegrationFingerprint.disable();
|
||||
}
|
||||
});
|
||||
|
||||
this.supportsBiometric =
|
||||
(await this.biometricsService.getBiometricsStatus()) === BiometricsStatus.Available;
|
||||
this.timerId = setInterval(async () => {
|
||||
this.supportsBiometric =
|
||||
(await this.biometricsService.getBiometricsStatus()) === BiometricsStatus.Available;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async saveVaultTimeout(newValue: VaultTimeout) {
|
||||
@@ -476,23 +484,20 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const needsSetup = await this.biometricsService.biometricsNeedsSetup();
|
||||
const supportsBiometricAutoSetup = await this.biometricsService.biometricsSupportsAutoSetup();
|
||||
const status = await this.biometricsService.getBiometricsStatus();
|
||||
|
||||
if (needsSetup) {
|
||||
if (supportsBiometricAutoSetup) {
|
||||
await this.biometricsService.biometricsSetup();
|
||||
} else {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "biometricsManualSetupTitle" },
|
||||
content: { key: "biometricsManualSetupDesc" },
|
||||
type: "warning",
|
||||
});
|
||||
if (confirmed) {
|
||||
this.platformUtilsService.launchUri("https://bitwarden.com/help/biometrics/");
|
||||
}
|
||||
return;
|
||||
if (status === BiometricsStatus.AutoSetupNeeded) {
|
||||
await this.biometricsService.setupBiometrics();
|
||||
} else if (status === BiometricsStatus.ManualSetupNeeded) {
|
||||
const confirmed = await this.dialogService.openSimpleDialog({
|
||||
title: { key: "biometricsManualSetupTitle" },
|
||||
content: { key: "biometricsManualSetupDesc" },
|
||||
type: "warning",
|
||||
});
|
||||
if (confirmed) {
|
||||
this.platformUtilsService.launchUri("https://bitwarden.com/help/biometrics/");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await this.biometricStateService.setBiometricUnlockEnabled(true);
|
||||
@@ -513,8 +518,13 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
await this.keyService.refreshAdditionalKeys();
|
||||
|
||||
const activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
|
||||
);
|
||||
// Validate the key is stored in case biometrics fail.
|
||||
const biometricSet = await this.keyService.hasUserKeyStored(KeySuffixOptions.Biometric);
|
||||
const biometricSet =
|
||||
(await this.biometricsService.getBiometricsStatusForUser(activeUserId)) ===
|
||||
BiometricsStatus.Available;
|
||||
this.form.controls.biometric.setValue(biometricSet, { emitEvent: false });
|
||||
if (!biometricSet) {
|
||||
await this.biometricStateService.setBiometricUnlockEnabled(false);
|
||||
@@ -779,6 +789,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
ngOnDestroy() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
clearInterval(this.timerId);
|
||||
}
|
||||
|
||||
get biometricText() {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
unauthGuardFn,
|
||||
} from "@bitwarden/angular/auth/guards";
|
||||
import { canAccessFeature } from "@bitwarden/angular/platform/guard/feature-flag.guard";
|
||||
import { twofactorRefactorSwap } from "@bitwarden/angular/utils/two-factor-component-refactor-route-swap";
|
||||
import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards";
|
||||
import {
|
||||
AnonLayoutWrapperComponent,
|
||||
@@ -46,7 +47,6 @@ import {
|
||||
VaultIcons,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { twofactorRefactorSwap } from "../../../../libs/angular/src/utils/two-factor-component-refactor-route-swap";
|
||||
import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component";
|
||||
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
|
||||
import { HintComponent } from "../auth/hint.component";
|
||||
|
||||
@@ -7,7 +7,8 @@ import { NgModule } from "@angular/core";
|
||||
|
||||
import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe";
|
||||
import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe";
|
||||
import { DialogModule, CalloutModule } from "@bitwarden/components";
|
||||
import { CalloutModule, DialogModule } from "@bitwarden/components";
|
||||
import { DecryptionFailureDialogComponent } from "@bitwarden/vault";
|
||||
|
||||
import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component";
|
||||
import { DeleteAccountComponent } from "../auth/delete-account.component";
|
||||
@@ -61,6 +62,7 @@ import { SendComponent } from "./tools/send/send.component";
|
||||
CalloutModule,
|
||||
DeleteAccountComponent,
|
||||
UserVerificationComponent,
|
||||
DecryptionFailureDialogComponent,
|
||||
],
|
||||
declarations: [
|
||||
AccessibilityCookieComponent,
|
||||
|
||||
@@ -17,6 +17,8 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
|
||||
import { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
|
||||
|
||||
type ActiveAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -90,6 +92,7 @@ export class AccountSwitcherComponent implements OnInit {
|
||||
private environmentService: EnvironmentService,
|
||||
private loginEmailService: LoginEmailServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
private biometricsService: DesktopBiometricsService,
|
||||
) {
|
||||
this.activeAccount$ = this.accountService.activeAccount$.pipe(
|
||||
switchMap(async (active) => {
|
||||
@@ -181,6 +184,7 @@ export class AccountSwitcherComponent implements OnInit {
|
||||
|
||||
async switch(userId: string) {
|
||||
this.close();
|
||||
await this.biometricsService.setShouldAutopromptNow(true);
|
||||
|
||||
this.disabled = true;
|
||||
const accountSwitchFinishedPromise = firstValueFrom(
|
||||
|
||||
@@ -102,7 +102,8 @@ import { DesktopLoginComponentService } from "../../auth/login/desktop-login-com
|
||||
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
|
||||
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
|
||||
import { DesktopFido2UserInterfaceService } from "../../autofill/services/desktop-fido2-user-interface.service";
|
||||
import { ElectronBiometricsService } from "../../key-management/biometrics/electron-biometrics.service";
|
||||
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
|
||||
import { RendererBiometricsService } from "../../key-management/biometrics/renderer-biometrics.service";
|
||||
import { DesktopLockComponentService } from "../../key-management/lock/services/desktop-lock-component.service";
|
||||
import { flagEnabled } from "../../platform/flags";
|
||||
import { DesktopSettingsService } from "../../platform/services/desktop-settings.service";
|
||||
@@ -142,7 +143,12 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider(InitService),
|
||||
safeProvider({
|
||||
provide: BiometricsService,
|
||||
useClass: ElectronBiometricsService,
|
||||
useClass: RendererBiometricsService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: DesktopBiometricsService,
|
||||
useClass: RendererBiometricsService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider(NativeMessagingService),
|
||||
@@ -241,6 +247,7 @@ const safeProviders: SafeProvider[] = [
|
||||
VaultTimeoutSettingsService,
|
||||
BiometricStateService,
|
||||
AccountServiceAbstraction,
|
||||
LogService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -302,6 +309,7 @@ const safeProviders: SafeProvider[] = [
|
||||
StateProvider,
|
||||
BiometricStateService,
|
||||
KdfConfigService,
|
||||
DesktopBiometricsService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import { OsBiometricService } from "./desktop.biometrics.service";
|
||||
|
||||
export default class NoopBiometricsService implements OsBiometricService {
|
||||
constructor() {}
|
||||
|
||||
async init() {}
|
||||
|
||||
async osSupportsBiometric(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async osBiometricsNeedsSetup(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async osBiometricsCanAutoSetup(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async osBiometricsSetup(): Promise<void> {}
|
||||
|
||||
async getBiometricKey(
|
||||
service: string,
|
||||
storageKey: string,
|
||||
clientKeyHalfB64: string,
|
||||
): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async setBiometricKey(
|
||||
service: string,
|
||||
storageKey: string,
|
||||
value: string,
|
||||
clientKeyPartB64: string | undefined,
|
||||
): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
async deleteBiometricKey(service: string, key: string): Promise<void> {}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
throw new Error("Not supported on this platform");
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
|
||||
import { BiometricMessage, BiometricAction } from "../../types/biometric-message";
|
||||
|
||||
import { DesktopBiometricsService } from "./desktop.biometrics.service";
|
||||
|
||||
export class BiometricsRendererIPCListener {
|
||||
constructor(
|
||||
private serviceName: string,
|
||||
private biometricService: DesktopBiometricsService,
|
||||
private logService: ConsoleLogService,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
ipcMain.handle("biometric", async (event: any, message: BiometricMessage) => {
|
||||
try {
|
||||
let serviceName = this.serviceName;
|
||||
message.keySuffix = "_" + (message.keySuffix ?? "");
|
||||
if (message.keySuffix !== "_") {
|
||||
serviceName += message.keySuffix;
|
||||
}
|
||||
|
||||
let val: string | boolean = null;
|
||||
|
||||
if (!message.action) {
|
||||
return val;
|
||||
}
|
||||
|
||||
switch (message.action) {
|
||||
case BiometricAction.EnabledForUser:
|
||||
if (!message.key || !message.userId) {
|
||||
break;
|
||||
}
|
||||
val = await this.biometricService.canAuthBiometric({
|
||||
service: serviceName,
|
||||
key: message.key,
|
||||
userId: message.userId,
|
||||
});
|
||||
break;
|
||||
case BiometricAction.OsSupported:
|
||||
val = await this.biometricService.supportsBiometric();
|
||||
break;
|
||||
case BiometricAction.NeedsSetup:
|
||||
val = await this.biometricService.biometricsNeedsSetup();
|
||||
break;
|
||||
case BiometricAction.Setup:
|
||||
await this.biometricService.biometricsSetup();
|
||||
break;
|
||||
case BiometricAction.CanAutoSetup:
|
||||
val = await this.biometricService.biometricsSupportsAutoSetup();
|
||||
break;
|
||||
default:
|
||||
}
|
||||
|
||||
return val;
|
||||
} catch (e) {
|
||||
this.logService.info(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,19 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricStateService } from "@bitwarden/key-management";
|
||||
import {
|
||||
BiometricsService,
|
||||
BiometricsStatus,
|
||||
BiometricStateService,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
import BiometricDarwinMain from "./biometric.darwin.main";
|
||||
import BiometricWindowsMain from "./biometric.windows.main";
|
||||
import { BiometricsService } from "./biometrics.service";
|
||||
import { OsBiometricService } from "./desktop.biometrics.service";
|
||||
import { MainBiometricsService } from "./main-biometrics.service";
|
||||
import OsBiometricsServiceLinux from "./os-biometrics-linux.service";
|
||||
import OsBiometricsServiceMac from "./os-biometrics-mac.service";
|
||||
import OsBiometricsServiceWindows from "./os-biometrics-windows.service";
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
jest.mock("@bitwarden/desktop-napi", () => {
|
||||
return {
|
||||
@@ -28,8 +33,7 @@ describe("biometrics tests", function () {
|
||||
const biometricStateService = mock<BiometricStateService>();
|
||||
|
||||
it("Should call the platformspecific methods", async () => {
|
||||
const userId = "userId-1" as UserId;
|
||||
const sut = new BiometricsService(
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
@@ -39,21 +43,15 @@ describe("biometrics tests", function () {
|
||||
);
|
||||
|
||||
const mockService = mock<OsBiometricService>();
|
||||
(sut as any).platformSpecificService = mockService;
|
||||
await sut.setEncryptionKeyHalf({ service: "test", key: "test", value: "test" });
|
||||
(sut as any).osBiometricsService = mockService;
|
||||
|
||||
await sut.canAuthBiometric({ service: "test", key: "test", userId });
|
||||
expect(mockService.osSupportsBiometric).toBeCalled();
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
sut.authenticateBiometric();
|
||||
await sut.authenticateBiometric();
|
||||
expect(mockService.authenticateBiometric).toBeCalled();
|
||||
});
|
||||
|
||||
describe("Should create a platform specific service", function () {
|
||||
it("Should create a biometrics service specific for Windows", () => {
|
||||
const sut = new BiometricsService(
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
@@ -62,13 +60,13 @@ describe("biometrics tests", function () {
|
||||
biometricStateService,
|
||||
);
|
||||
|
||||
const internalService = (sut as any).platformSpecificService;
|
||||
const internalService = (sut as any).osBiometricsService;
|
||||
expect(internalService).not.toBeNull();
|
||||
expect(internalService).toBeInstanceOf(BiometricWindowsMain);
|
||||
expect(internalService).toBeInstanceOf(OsBiometricsServiceWindows);
|
||||
});
|
||||
|
||||
it("Should create a biometrics service specific for MacOs", () => {
|
||||
const sut = new BiometricsService(
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
@@ -76,19 +74,33 @@ describe("biometrics tests", function () {
|
||||
"darwin",
|
||||
biometricStateService,
|
||||
);
|
||||
const internalService = (sut as any).platformSpecificService;
|
||||
const internalService = (sut as any).osBiometricsService;
|
||||
expect(internalService).not.toBeNull();
|
||||
expect(internalService).toBeInstanceOf(BiometricDarwinMain);
|
||||
expect(internalService).toBeInstanceOf(OsBiometricsServiceMac);
|
||||
});
|
||||
|
||||
it("Should create a biometrics service specific for Linux", () => {
|
||||
const sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
messagingService,
|
||||
"linux",
|
||||
biometricStateService,
|
||||
);
|
||||
|
||||
const internalService = (sut as any).osBiometricsService;
|
||||
expect(internalService).not.toBeNull();
|
||||
expect(internalService).toBeInstanceOf(OsBiometricsServiceLinux);
|
||||
});
|
||||
});
|
||||
|
||||
describe("can auth biometric", () => {
|
||||
let sut: BiometricsService;
|
||||
let innerService: MockProxy<OsBiometricService>;
|
||||
const userId = "userId-1" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new BiometricsService(
|
||||
sut = new MainBiometricsService(
|
||||
i18nService,
|
||||
windowMain,
|
||||
logService,
|
||||
@@ -98,34 +110,78 @@ describe("biometrics tests", function () {
|
||||
);
|
||||
|
||||
innerService = mock();
|
||||
(sut as any).platformSpecificService = innerService;
|
||||
(sut as any).osBiometricsService = innerService;
|
||||
});
|
||||
|
||||
it("should return false if client key half is required and not provided", async () => {
|
||||
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(true);
|
||||
it("should return the correct biometric status for system status", async () => {
|
||||
const testCases = [
|
||||
// happy path
|
||||
[true, false, false, BiometricsStatus.Available],
|
||||
[false, true, true, BiometricsStatus.AutoSetupNeeded],
|
||||
[false, true, false, BiometricsStatus.ManualSetupNeeded],
|
||||
[false, false, false, BiometricsStatus.HardwareUnavailable],
|
||||
|
||||
const result = await sut.canAuthBiometric({ service: "test", key: "test", userId });
|
||||
// should not happen
|
||||
[false, false, true, BiometricsStatus.HardwareUnavailable],
|
||||
[true, true, true, BiometricsStatus.Available],
|
||||
[true, true, false, BiometricsStatus.Available],
|
||||
[true, false, true, BiometricsStatus.Available],
|
||||
];
|
||||
|
||||
expect(result).toBe(false);
|
||||
for (const [supportsBiometric, needsSetup, canAutoSetup, expected] of testCases) {
|
||||
innerService.osSupportsBiometric.mockResolvedValue(supportsBiometric as boolean);
|
||||
innerService.osBiometricsNeedsSetup.mockResolvedValue(needsSetup as boolean);
|
||||
innerService.osBiometricsCanAutoSetup.mockResolvedValue(canAutoSetup as boolean);
|
||||
|
||||
const actual = await sut.getBiometricsStatus();
|
||||
expect(actual).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("should call osSupportsBiometric if client key half is provided", async () => {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
sut.setEncryptionKeyHalf({ service: "test", key: "test", value: "test" });
|
||||
it("should return the correct biometric status for user status", async () => {
|
||||
const testCases = [
|
||||
// system status, biometric unlock enabled, require password on start, has key half, result
|
||||
[BiometricsStatus.Available, false, false, false, BiometricsStatus.NotEnabledLocally],
|
||||
[BiometricsStatus.Available, false, true, false, BiometricsStatus.NotEnabledLocally],
|
||||
[BiometricsStatus.Available, false, false, true, BiometricsStatus.NotEnabledLocally],
|
||||
[BiometricsStatus.Available, false, true, true, BiometricsStatus.NotEnabledLocally],
|
||||
|
||||
await sut.canAuthBiometric({ service: "test", key: "test", userId });
|
||||
expect(innerService.osSupportsBiometric).toBeCalled();
|
||||
});
|
||||
[
|
||||
BiometricsStatus.PlatformUnsupported,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
BiometricsStatus.PlatformUnsupported,
|
||||
],
|
||||
[BiometricsStatus.ManualSetupNeeded, true, true, true, BiometricsStatus.ManualSetupNeeded],
|
||||
[BiometricsStatus.AutoSetupNeeded, true, true, true, BiometricsStatus.AutoSetupNeeded],
|
||||
|
||||
it("should call osSupportBiometric if client key half is not required", async () => {
|
||||
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(false);
|
||||
innerService.osSupportsBiometric.mockResolvedValue(true);
|
||||
[BiometricsStatus.Available, true, false, true, BiometricsStatus.Available],
|
||||
[BiometricsStatus.Available, true, true, false, BiometricsStatus.UnlockNeeded],
|
||||
[BiometricsStatus.Available, true, false, true, BiometricsStatus.Available],
|
||||
];
|
||||
|
||||
const result = await sut.canAuthBiometric({ service: "test", key: "test", userId });
|
||||
for (const [
|
||||
systemStatus,
|
||||
unlockEnabled,
|
||||
requirePasswordOnStart,
|
||||
hasKeyHalf,
|
||||
expected,
|
||||
] of testCases) {
|
||||
sut.getBiometricsStatus = jest.fn().mockResolvedValue(systemStatus as BiometricsStatus);
|
||||
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(unlockEnabled as boolean);
|
||||
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(
|
||||
requirePasswordOnStart as boolean,
|
||||
);
|
||||
(sut as any).clientKeyHalves = new Map();
|
||||
const userId = "test" as UserId;
|
||||
if (hasKeyHalf) {
|
||||
(sut as any).clientKeyHalves.set(userId, "test");
|
||||
}
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(innerService.osSupportsBiometric).toHaveBeenCalled();
|
||||
const actual = await sut.getBiometricsStatusForUser(userId);
|
||||
expect(actual).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
import { DesktopBiometricsService, OsBiometricService } from "./desktop.biometrics.service";
|
||||
|
||||
export class BiometricsService extends DesktopBiometricsService {
|
||||
private platformSpecificService: OsBiometricService;
|
||||
private clientKeyHalves = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
private logService: LogService,
|
||||
private messagingService: MessagingService,
|
||||
private platform: NodeJS.Platform,
|
||||
private biometricStateService: BiometricStateService,
|
||||
) {
|
||||
super();
|
||||
this.loadPlatformSpecificService(this.platform);
|
||||
}
|
||||
|
||||
private loadPlatformSpecificService(platform: NodeJS.Platform) {
|
||||
if (platform === "win32") {
|
||||
this.loadWindowsHelloService();
|
||||
} else if (platform === "darwin") {
|
||||
this.loadMacOSService();
|
||||
} else if (platform === "linux") {
|
||||
this.loadUnixService();
|
||||
} else {
|
||||
this.loadNoopBiometricsService();
|
||||
}
|
||||
}
|
||||
|
||||
private loadWindowsHelloService() {
|
||||
// eslint-disable-next-line
|
||||
const BiometricWindowsMain = require("./biometric.windows.main").default;
|
||||
this.platformSpecificService = new BiometricWindowsMain(
|
||||
this.i18nService,
|
||||
this.windowMain,
|
||||
this.logService,
|
||||
);
|
||||
}
|
||||
|
||||
private loadMacOSService() {
|
||||
// eslint-disable-next-line
|
||||
const BiometricDarwinMain = require("./biometric.darwin.main").default;
|
||||
this.platformSpecificService = new BiometricDarwinMain(this.i18nService);
|
||||
}
|
||||
|
||||
private loadUnixService() {
|
||||
// eslint-disable-next-line
|
||||
const BiometricUnixMain = require("./biometric.unix.main").default;
|
||||
this.platformSpecificService = new BiometricUnixMain(this.i18nService, this.windowMain);
|
||||
}
|
||||
|
||||
private loadNoopBiometricsService() {
|
||||
// eslint-disable-next-line
|
||||
const NoopBiometricsService = require("./biometric.noop.main").default;
|
||||
this.platformSpecificService = new NoopBiometricsService();
|
||||
}
|
||||
|
||||
async supportsBiometric() {
|
||||
return await this.platformSpecificService.osSupportsBiometric();
|
||||
}
|
||||
|
||||
async biometricsNeedsSetup() {
|
||||
return await this.platformSpecificService.osBiometricsNeedsSetup();
|
||||
}
|
||||
|
||||
async biometricsSupportsAutoSetup() {
|
||||
return await this.platformSpecificService.osBiometricsCanAutoSetup();
|
||||
}
|
||||
|
||||
async biometricsSetup() {
|
||||
await this.platformSpecificService.osBiometricsSetup();
|
||||
}
|
||||
|
||||
async canAuthBiometric({
|
||||
service,
|
||||
key,
|
||||
userId,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
userId: UserId;
|
||||
}): Promise<boolean> {
|
||||
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
|
||||
const clientKeyHalfB64 = this.getClientKeyHalf(service, key);
|
||||
const clientKeyHalfSatisfied = !requireClientKeyHalf || !!clientKeyHalfB64;
|
||||
return clientKeyHalfSatisfied && (await this.supportsBiometric());
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
let result = false;
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.interruptProcessReload(
|
||||
() => {
|
||||
return this.platformSpecificService.authenticateBiometric();
|
||||
},
|
||||
(response) => {
|
||||
result = response;
|
||||
return !response;
|
||||
},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
async isBiometricUnlockAvailable(): Promise<boolean> {
|
||||
return await this.platformSpecificService.osSupportsBiometric();
|
||||
}
|
||||
|
||||
async getBiometricKey(service: string, storageKey: string): Promise<string | null> {
|
||||
return await this.interruptProcessReload(async () => {
|
||||
await this.enforceClientKeyHalf(service, storageKey);
|
||||
|
||||
return await this.platformSpecificService.getBiometricKey(
|
||||
service,
|
||||
storageKey,
|
||||
this.getClientKeyHalf(service, storageKey),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async setBiometricKey(service: string, storageKey: string, value: string): Promise<void> {
|
||||
await this.enforceClientKeyHalf(service, storageKey);
|
||||
|
||||
return await this.platformSpecificService.setBiometricKey(
|
||||
service,
|
||||
storageKey,
|
||||
value,
|
||||
this.getClientKeyHalf(service, storageKey),
|
||||
);
|
||||
}
|
||||
|
||||
/** Registers the client-side encryption key half for the OS stored Biometric key. The other half is protected by the OS.*/
|
||||
async setEncryptionKeyHalf({
|
||||
service,
|
||||
key,
|
||||
value,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}): Promise<void> {
|
||||
if (value == null) {
|
||||
this.clientKeyHalves.delete(this.clientKeyHalfKey(service, key));
|
||||
} else {
|
||||
this.clientKeyHalves.set(this.clientKeyHalfKey(service, key), value);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBiometricKey(service: string, storageKey: string): Promise<void> {
|
||||
this.clientKeyHalves.delete(this.clientKeyHalfKey(service, storageKey));
|
||||
return await this.platformSpecificService.deleteBiometricKey(service, storageKey);
|
||||
}
|
||||
|
||||
private async interruptProcessReload<T>(
|
||||
callback: () => Promise<T>,
|
||||
restartReloadCallback: (arg: T) => boolean = () => false,
|
||||
): Promise<T> {
|
||||
this.messagingService.send("cancelProcessReload");
|
||||
let restartReload = false;
|
||||
let response: T;
|
||||
try {
|
||||
response = await callback();
|
||||
restartReload ||= restartReloadCallback(response);
|
||||
} catch (error) {
|
||||
if (error.message === "Biometric authentication failed") {
|
||||
restartReload = false;
|
||||
} else {
|
||||
restartReload = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (restartReload) {
|
||||
this.messagingService.send("startProcessReload");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private clientKeyHalfKey(service: string, key: string): string {
|
||||
return `${service}:${key}`;
|
||||
}
|
||||
|
||||
private getClientKeyHalf(service: string, key: string): string | undefined {
|
||||
return this.clientKeyHalves.get(this.clientKeyHalfKey(service, key)) ?? undefined;
|
||||
}
|
||||
|
||||
private async enforceClientKeyHalf(service: string, storageKey: string): Promise<void> {
|
||||
// The first half of the storageKey is the userId, separated by `_`
|
||||
// We need to extract from the service because the active user isn't properly synced to the main process,
|
||||
// So we can't use the observables on `biometricStateService`
|
||||
const [userId] = storageKey.split("_");
|
||||
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(
|
||||
userId as UserId,
|
||||
);
|
||||
const clientKeyHalfB64 = this.getClientKeyHalf(service, storageKey);
|
||||
|
||||
if (requireClientKeyHalf && !clientKeyHalfB64) {
|
||||
throw new Error("Biometric key requirements not met. No client key half provided.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
/**
|
||||
@@ -5,58 +6,10 @@ import { BiometricsService } from "@bitwarden/key-management";
|
||||
* specifically for the main process.
|
||||
*/
|
||||
export abstract class DesktopBiometricsService extends BiometricsService {
|
||||
abstract canAuthBiometric({
|
||||
service,
|
||||
key,
|
||||
userId,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
userId: string;
|
||||
}): Promise<boolean>;
|
||||
abstract getBiometricKey(service: string, key: string): Promise<string | null>;
|
||||
abstract setBiometricKey(service: string, key: string, value: string): Promise<void>;
|
||||
abstract setEncryptionKeyHalf({
|
||||
service,
|
||||
key,
|
||||
value,
|
||||
}: {
|
||||
service: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}): void;
|
||||
abstract deleteBiometricKey(service: string, key: string): Promise<void>;
|
||||
}
|
||||
abstract setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void>;
|
||||
abstract deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void>;
|
||||
|
||||
export interface OsBiometricService {
|
||||
osSupportsBiometric(): Promise<boolean>;
|
||||
/**
|
||||
* Check whether support for biometric unlock requires setup. This can be automatic or manual.
|
||||
*
|
||||
* @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place)
|
||||
*/
|
||||
osBiometricsNeedsSetup: () => Promise<boolean>;
|
||||
/**
|
||||
* Check whether biometrics can be automatically setup, or requires user interaction.
|
||||
*
|
||||
* @returns true if biometrics support can be automatically setup, false if it requires user interaction.
|
||||
*/
|
||||
osBiometricsCanAutoSetup: () => Promise<boolean>;
|
||||
/**
|
||||
* Starts automatic biometric setup, which places the required configuration files / changes the required settings.
|
||||
*/
|
||||
osBiometricsSetup: () => Promise<void>;
|
||||
authenticateBiometric(): Promise<boolean>;
|
||||
getBiometricKey(
|
||||
service: string,
|
||||
key: string,
|
||||
clientKeyHalfB64: string | undefined,
|
||||
): Promise<string | null>;
|
||||
setBiometricKey(
|
||||
service: string,
|
||||
key: string,
|
||||
value: string,
|
||||
clientKeyHalfB64: string | undefined,
|
||||
): Promise<void>;
|
||||
deleteBiometricKey(service: string, key: string): Promise<void>;
|
||||
abstract setupBiometrics(): Promise<void>;
|
||||
|
||||
abstract setClientKeyHalfForUser(userId: UserId, value: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
/**
|
||||
* This service implement the base biometrics service to provide desktop specific functions,
|
||||
* specifically for the renderer process by passing messages to the main process.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ElectronBiometricsService extends BiometricsService {
|
||||
async supportsBiometric(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.osSupported();
|
||||
}
|
||||
|
||||
async isBiometricUnlockAvailable(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.osSupported();
|
||||
}
|
||||
|
||||
/** This method is used to authenticate the user presence _only_.
|
||||
* It should not be used in the process to retrieve
|
||||
* biometric keys, which has a separate authentication mechanism.
|
||||
* For biometric keys, invoke "keytar" with a biometric key suffix */
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.authenticate();
|
||||
}
|
||||
|
||||
async biometricsNeedsSetup(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.biometricsNeedsSetup();
|
||||
}
|
||||
|
||||
async biometricsSupportsAutoSetup(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.biometricsCanAutoSetup();
|
||||
}
|
||||
|
||||
async biometricsSetup(): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.biometricsSetup();
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./desktop.biometrics.service";
|
||||
export * from "./biometrics.service";
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { BiometricMessage, BiometricAction } from "../../types/biometric-message";
|
||||
|
||||
import { DesktopBiometricsService } from "./desktop.biometrics.service";
|
||||
|
||||
export class MainBiometricsIPCListener {
|
||||
constructor(
|
||||
private biometricService: DesktopBiometricsService,
|
||||
private logService: ConsoleLogService,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
ipcMain.handle("biometric", async (event: any, message: BiometricMessage) => {
|
||||
try {
|
||||
if (!message.action) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.action) {
|
||||
case BiometricAction.Authenticate:
|
||||
return await this.biometricService.authenticateWithBiometrics();
|
||||
case BiometricAction.GetStatus:
|
||||
return await this.biometricService.getBiometricsStatus();
|
||||
case BiometricAction.UnlockForUser:
|
||||
return await this.biometricService.unlockWithBiometricsForUser(
|
||||
message.userId as UserId,
|
||||
);
|
||||
case BiometricAction.GetStatusForUser:
|
||||
return await this.biometricService.getBiometricsStatusForUser(message.userId as UserId);
|
||||
case BiometricAction.SetKeyForUser:
|
||||
return await this.biometricService.setBiometricProtectedUnlockKeyForUser(
|
||||
message.userId as UserId,
|
||||
message.key,
|
||||
);
|
||||
case BiometricAction.RemoveKeyForUser:
|
||||
return await this.biometricService.deleteBiometricUnlockKeyForUser(
|
||||
message.userId as UserId,
|
||||
);
|
||||
case BiometricAction.SetClientKeyHalf:
|
||||
return await this.biometricService.setClientKeyHalfForUser(
|
||||
message.userId as UserId,
|
||||
message.key,
|
||||
);
|
||||
case BiometricAction.Setup:
|
||||
return await this.biometricService.setupBiometrics();
|
||||
|
||||
case BiometricAction.SetShouldAutoprompt:
|
||||
return await this.biometricService.setShouldAutopromptNow(message.data as boolean);
|
||||
case BiometricAction.GetShouldAutoprompt:
|
||||
return await this.biometricService.getShouldAutopromptNow();
|
||||
default:
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.info(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsStatus, BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
import { DesktopBiometricsService } from "./desktop.biometrics.service";
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
export class MainBiometricsService extends DesktopBiometricsService {
|
||||
private osBiometricsService: OsBiometricService;
|
||||
private clientKeyHalves = new Map<string, string>();
|
||||
private shouldAutoPrompt = true;
|
||||
|
||||
constructor(
|
||||
private i18nService: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
private logService: LogService,
|
||||
private messagingService: MessagingService,
|
||||
private platform: NodeJS.Platform,
|
||||
private biometricStateService: BiometricStateService,
|
||||
) {
|
||||
super();
|
||||
this.loadOsBiometricService(this.platform);
|
||||
}
|
||||
|
||||
private loadOsBiometricService(platform: NodeJS.Platform) {
|
||||
if (platform === "win32") {
|
||||
// eslint-disable-next-line
|
||||
const OsBiometricsServiceWindows = require("./os-biometrics-windows.service").default;
|
||||
this.osBiometricsService = new OsBiometricsServiceWindows(
|
||||
this.i18nService,
|
||||
this.windowMain,
|
||||
this.logService,
|
||||
);
|
||||
} else if (platform === "darwin") {
|
||||
// eslint-disable-next-line
|
||||
const OsBiometricsServiceMac = require("./os-biometrics-mac.service").default;
|
||||
this.osBiometricsService = new OsBiometricsServiceMac(this.i18nService);
|
||||
} else if (platform === "linux") {
|
||||
// eslint-disable-next-line
|
||||
const OsBiometricsServiceLinux = require("./os-biometrics-linux.service").default;
|
||||
this.osBiometricsService = new OsBiometricsServiceLinux(this.i18nService, this.windowMain);
|
||||
} else {
|
||||
throw new Error("Unsupported platform");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status of biometrics for the platform. Biometrics status for the platform can be one of:
|
||||
* - Available: Biometrics are available and can be used (On windows hello, (touch id (for now)) and polkit, this MAY fall back to password)
|
||||
* - HardwareUnavailable: Biometrics are not available on the platform
|
||||
* - ManualSetupNeeded: In order to use biometrics, the user must perform manual steps (linux only)
|
||||
* - AutoSetupNeeded: In order to use biometrics, the user must perform automatic steps (linux only)
|
||||
* @returns the status of the biometrics of the platform
|
||||
*/
|
||||
async getBiometricsStatus(): Promise<BiometricsStatus> {
|
||||
if (!(await this.osBiometricsService.osSupportsBiometric())) {
|
||||
if (await this.osBiometricsService.osBiometricsNeedsSetup()) {
|
||||
if (await this.osBiometricsService.osBiometricsCanAutoSetup()) {
|
||||
return BiometricsStatus.AutoSetupNeeded;
|
||||
} else {
|
||||
return BiometricsStatus.ManualSetupNeeded;
|
||||
}
|
||||
}
|
||||
|
||||
return BiometricsStatus.HardwareUnavailable;
|
||||
}
|
||||
return BiometricsStatus.Available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status of biometric unlock for a specific user. For this, biometric unlock needs to be set up for the user in the settings.
|
||||
* Next, biometrics unlock needs to be available on the platform level. If "masterpassword reprompt" is enabled, a client key half (set on first unlock) for this user
|
||||
* needs to be held in memory.
|
||||
* @param userId the user to check the biometric unlock status for
|
||||
* @returns the status of the biometric unlock for the user
|
||||
*/
|
||||
async getBiometricsStatusForUser(userId: UserId): Promise<BiometricsStatus> {
|
||||
if (!(await this.biometricStateService.getBiometricUnlockEnabled(userId))) {
|
||||
return BiometricsStatus.NotEnabledLocally;
|
||||
}
|
||||
|
||||
const platformStatus = await this.getBiometricsStatus();
|
||||
if (!(platformStatus === BiometricsStatus.Available)) {
|
||||
return platformStatus;
|
||||
}
|
||||
|
||||
const requireClientKeyHalf = await this.biometricStateService.getRequirePasswordOnStart(userId);
|
||||
const clientKeyHalfB64 = this.clientKeyHalves.get(userId);
|
||||
const clientKeyHalfSatisfied = !requireClientKeyHalf || !!clientKeyHalfB64;
|
||||
if (!clientKeyHalfSatisfied) {
|
||||
return BiometricsStatus.UnlockNeeded;
|
||||
}
|
||||
|
||||
return BiometricsStatus.Available;
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
return await this.osBiometricsService.authenticateBiometric();
|
||||
}
|
||||
|
||||
async setupBiometrics(): Promise<void> {
|
||||
return await this.osBiometricsService.osBiometricsSetup();
|
||||
}
|
||||
|
||||
async setClientKeyHalfForUser(userId: UserId, value: string): Promise<void> {
|
||||
this.clientKeyHalves.set(userId, value);
|
||||
}
|
||||
|
||||
async authenticateWithBiometrics(): Promise<boolean> {
|
||||
return await this.osBiometricsService.authenticateBiometric();
|
||||
}
|
||||
|
||||
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
|
||||
return SymmetricCryptoKey.fromString(
|
||||
await this.osBiometricsService.getBiometricKey(
|
||||
"Bitwarden_biometric",
|
||||
`${userId}_user_biometric`,
|
||||
this.clientKeyHalves.get(userId),
|
||||
),
|
||||
) as UserKey;
|
||||
}
|
||||
|
||||
async setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void> {
|
||||
const service = "Bitwarden_biometric";
|
||||
const storageKey = `${userId}_user_biometric`;
|
||||
if (!this.clientKeyHalves.has(userId)) {
|
||||
throw new Error("No client key half provided for user");
|
||||
}
|
||||
|
||||
return await this.osBiometricsService.setBiometricKey(
|
||||
service,
|
||||
storageKey,
|
||||
value,
|
||||
this.clientKeyHalves.get(userId),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void> {
|
||||
return await this.osBiometricsService.deleteBiometricKey(
|
||||
"Bitwarden_biometric",
|
||||
`${userId}_user_biometric`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether to auto-prompt the user for biometric unlock; this can be used to prevent auto-prompting being initiated by a process reload.
|
||||
* Reasons for enabling auto prompt include: Starting the app, un-minimizing the app, manually account switching
|
||||
* @param value Whether to auto-prompt the user for biometric unlock
|
||||
*/
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {
|
||||
this.shouldAutoPrompt = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get whether to auto-prompt the user for biometric unlock; If the user is auto-prompted, setShouldAutopromptNow should be immediately called with false in order to prevent another auto-prompt.
|
||||
* @returns Whether to auto-prompt the user for biometric unlock
|
||||
*/
|
||||
async getShouldAutopromptNow(): Promise<boolean> {
|
||||
return this.shouldAutoPrompt;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { biometrics, passwords } from "@bitwarden/desktop-napi";
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
import { isFlatpak, isLinux, isSnapStore } from "../../utils";
|
||||
|
||||
import { OsBiometricService } from "./desktop.biometrics.service";
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
const polkitPolicy = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC
|
||||
@@ -30,7 +30,7 @@ const polkitPolicy = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
const policyFileName = "com.bitwarden.Bitwarden.policy";
|
||||
const policyPath = "/usr/share/polkit-1/actions/";
|
||||
|
||||
export default class BiometricUnixMain implements OsBiometricService {
|
||||
export default class OsBiometricsServiceLinux implements OsBiometricService {
|
||||
constructor(
|
||||
private i18nservice: I18nService,
|
||||
private windowMain: WindowMain,
|
||||
@@ -3,9 +3,9 @@ import { systemPreferences } from "electron";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { passwords } from "@bitwarden/desktop-napi";
|
||||
|
||||
import { OsBiometricService } from "./desktop.biometrics.service";
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
export default class BiometricDarwinMain implements OsBiometricService {
|
||||
export default class OsBiometricsServiceMac implements OsBiometricService {
|
||||
constructor(private i18nservice: I18nService) {}
|
||||
|
||||
async osSupportsBiometric(): Promise<boolean> {
|
||||
@@ -8,12 +8,12 @@ import { biometrics, passwords } from "@bitwarden/desktop-napi";
|
||||
|
||||
import { WindowMain } from "../../main/window.main";
|
||||
|
||||
import { OsBiometricService } from "./desktop.biometrics.service";
|
||||
import { OsBiometricService } from "./os-biometrics.service";
|
||||
|
||||
const KEY_WITNESS_SUFFIX = "_witness";
|
||||
const WITNESS_VALUE = "known key";
|
||||
|
||||
export default class BiometricWindowsMain implements OsBiometricService {
|
||||
export default class OsBiometricsServiceWindows implements OsBiometricService {
|
||||
// Use set helper method instead of direct access
|
||||
private _iv: string | null = null;
|
||||
// Use getKeyMaterial helper instead of direct access
|
||||
@@ -113,13 +113,19 @@ export default class BiometricWindowsMain implements OsBiometricService {
|
||||
this._iv = keyMaterial.ivB64;
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
key_material: {
|
||||
osKeyPartB64: this._osKeyHalf,
|
||||
clientKeyPartB64: clientKeyHalfB64,
|
||||
},
|
||||
ivB64: this._iv,
|
||||
};
|
||||
|
||||
// napi-rs fails to convert null values
|
||||
if (result.key_material.clientKeyPartB64 == null) {
|
||||
delete result.key_material.clientKeyPartB64;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey
|
||||
@@ -211,10 +217,17 @@ export default class BiometricWindowsMain implements OsBiometricService {
|
||||
clientKeyPartB64: string,
|
||||
): biometrics.KeyMaterial {
|
||||
const key = symmetricKey?.macKeyB64 ?? symmetricKey?.keyB64;
|
||||
return {
|
||||
|
||||
const result = {
|
||||
osKeyPartB64: key,
|
||||
clientKeyPartB64,
|
||||
};
|
||||
|
||||
// napi-rs fails to convert null values
|
||||
if (result.clientKeyPartB64 == null) {
|
||||
delete result.clientKeyPartB64;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async osBiometricsNeedsSetup() {
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface OsBiometricService {
|
||||
osSupportsBiometric(): Promise<boolean>;
|
||||
/**
|
||||
* Check whether support for biometric unlock requires setup. This can be automatic or manual.
|
||||
*
|
||||
* @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place)
|
||||
*/
|
||||
osBiometricsNeedsSetup: () => Promise<boolean>;
|
||||
/**
|
||||
* Check whether biometrics can be automatically setup, or requires user interaction.
|
||||
*
|
||||
* @returns true if biometrics support can be automatically setup, false if it requires user interaction.
|
||||
*/
|
||||
osBiometricsCanAutoSetup: () => Promise<boolean>;
|
||||
/**
|
||||
* Starts automatic biometric setup, which places the required configuration files / changes the required settings.
|
||||
*/
|
||||
osBiometricsSetup: () => Promise<void>;
|
||||
authenticateBiometric(): Promise<boolean>;
|
||||
getBiometricKey(
|
||||
service: string,
|
||||
key: string,
|
||||
clientKeyHalfB64: string | undefined,
|
||||
): Promise<string | null>;
|
||||
setBiometricKey(
|
||||
service: string,
|
||||
key: string,
|
||||
value: string,
|
||||
clientKeyHalfB64: string | undefined,
|
||||
): Promise<void>;
|
||||
deleteBiometricKey(service: string, key: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
import { DesktopBiometricsService } from "./desktop.biometrics.service";
|
||||
|
||||
/**
|
||||
* This service implement the base biometrics service to provide desktop specific functions,
|
||||
* specifically for the renderer process by passing messages to the main process.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RendererBiometricsService extends DesktopBiometricsService {
|
||||
async authenticateWithBiometrics(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.authenticateWithBiometrics();
|
||||
}
|
||||
|
||||
async getBiometricsStatus(): Promise<BiometricsStatus> {
|
||||
return await ipc.keyManagement.biometric.getBiometricsStatus();
|
||||
}
|
||||
|
||||
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
|
||||
return await ipc.keyManagement.biometric.unlockWithBiometricsForUser(userId);
|
||||
}
|
||||
|
||||
async getBiometricsStatusForUser(id: UserId): Promise<BiometricsStatus> {
|
||||
return await ipc.keyManagement.biometric.getBiometricsStatusForUser(id);
|
||||
}
|
||||
|
||||
async setBiometricProtectedUnlockKeyForUser(userId: UserId, value: string): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.setBiometricProtectedUnlockKeyForUser(userId, value);
|
||||
}
|
||||
|
||||
async deleteBiometricUnlockKeyForUser(userId: UserId): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.deleteBiometricUnlockKeyForUser(userId);
|
||||
}
|
||||
|
||||
async setupBiometrics(): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.setupBiometrics();
|
||||
}
|
||||
|
||||
async setClientKeyHalfForUser(userId: UserId, value: string): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.setClientKeyHalf(userId, value);
|
||||
}
|
||||
|
||||
async getShouldAutopromptNow(): Promise<boolean> {
|
||||
return await ipc.keyManagement.biometric.getShouldAutoprompt();
|
||||
}
|
||||
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {
|
||||
return await ipc.keyManagement.biometric.setShouldAutoprompt(value);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vaul
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService, BiometricsService } from "@bitwarden/key-management";
|
||||
import { BiometricsDisableReason, UnlockOptions } from "@bitwarden/key-management/angular";
|
||||
import { KeyService, BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { UnlockOptions } from "@bitwarden/key-management/angular";
|
||||
|
||||
import { DesktopLockComponentService } from "./desktop-lock-component.service";
|
||||
|
||||
@@ -140,11 +140,7 @@ describe("DesktopLockComponentService", () => {
|
||||
describe("getAvailableUnlockOptions$", () => {
|
||||
interface MockInputs {
|
||||
hasMasterPassword: boolean;
|
||||
osSupportsBiometric: boolean;
|
||||
biometricLockSet: boolean;
|
||||
biometricReady: boolean;
|
||||
hasBiometricEncryptedUserKeyStored: boolean;
|
||||
platformSupportsSecureStorage: boolean;
|
||||
biometricsStatus: BiometricsStatus;
|
||||
pinDecryptionAvailable: boolean;
|
||||
}
|
||||
|
||||
@@ -153,11 +149,7 @@ describe("DesktopLockComponentService", () => {
|
||||
// MP + PIN + Biometrics available
|
||||
{
|
||||
hasMasterPassword: true,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
pinDecryptionAvailable: true,
|
||||
},
|
||||
{
|
||||
@@ -169,7 +161,7 @@ describe("DesktopLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -177,11 +169,7 @@ describe("DesktopLockComponentService", () => {
|
||||
// PIN + Biometrics available
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
pinDecryptionAvailable: true,
|
||||
},
|
||||
{
|
||||
@@ -193,43 +181,16 @@ describe("DesktopLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// Biometrics available: user key stored with no secure storage
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: false,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
masterPassword: {
|
||||
enabled: false,
|
||||
},
|
||||
pin: {
|
||||
enabled: false,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.Available,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// Biometrics available: no user key stored with no secure storage
|
||||
// Biometric auth is available, but not unlock since there is no way to access the userkey
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: false,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: false,
|
||||
biometricsStatus: BiometricsStatus.NotEnabledLocally,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
@@ -240,8 +201,8 @@ describe("DesktopLockComponentService", () => {
|
||||
enabled: false,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: true,
|
||||
disableReason: null,
|
||||
enabled: false,
|
||||
biometricsStatus: BiometricsStatus.NotEnabledLocally,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -249,11 +210,7 @@ describe("DesktopLockComponentService", () => {
|
||||
// Biometrics not available: biometric not ready
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: false,
|
||||
platformSupportsSecureStorage: true,
|
||||
biometricsStatus: BiometricsStatus.HardwareUnavailable,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
@@ -265,55 +222,7 @@ describe("DesktopLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.SystemBiometricsUnavailable,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// Biometrics not available: biometric lock not set
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: false,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
masterPassword: {
|
||||
enabled: false,
|
||||
},
|
||||
pin: {
|
||||
enabled: false,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
// Biometrics not available: user key not stored
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: true,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: false,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
masterPassword: {
|
||||
enabled: false,
|
||||
},
|
||||
pin: {
|
||||
enabled: false,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.EncryptedKeysUnavailable,
|
||||
biometricsStatus: BiometricsStatus.HardwareUnavailable,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -321,11 +230,7 @@ describe("DesktopLockComponentService", () => {
|
||||
// Biometrics not available: OS doesn't support
|
||||
{
|
||||
hasMasterPassword: false,
|
||||
osSupportsBiometric: false,
|
||||
biometricLockSet: true,
|
||||
hasBiometricEncryptedUserKeyStored: true,
|
||||
biometricReady: true,
|
||||
platformSupportsSecureStorage: true,
|
||||
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||
pinDecryptionAvailable: false,
|
||||
},
|
||||
{
|
||||
@@ -337,7 +242,7 @@ describe("DesktopLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: BiometricsDisableReason.NotSupportedOnOperatingSystem,
|
||||
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -355,13 +260,8 @@ describe("DesktopLockComponentService", () => {
|
||||
);
|
||||
|
||||
// Biometrics
|
||||
biometricsService.supportsBiometric.mockResolvedValue(mockInputs.osSupportsBiometric);
|
||||
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(mockInputs.biometricLockSet);
|
||||
keyService.hasUserKeyStored.mockResolvedValue(mockInputs.hasBiometricEncryptedUserKeyStored);
|
||||
platformUtilsService.supportsSecureStorage.mockReturnValue(
|
||||
mockInputs.platformSupportsSecureStorage,
|
||||
);
|
||||
biometricEnabledMock.mockResolvedValue(mockInputs.biometricReady);
|
||||
// TODO: FIXME
|
||||
biometricsService.getBiometricsStatusForUser.mockResolvedValue(mockInputs.biometricsStatus);
|
||||
|
||||
// PIN
|
||||
pinService.isPinDecryptionAvailable.mockResolvedValue(mockInputs.pinDecryptionAvailable);
|
||||
|
||||
@@ -5,25 +5,17 @@ import {
|
||||
PinServiceAbstraction,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/abstractions/vault-timeout/vault-timeout-settings.service";
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService, BiometricsService } from "@bitwarden/key-management";
|
||||
import {
|
||||
BiometricsDisableReason,
|
||||
LockComponentService,
|
||||
UnlockOptions,
|
||||
} from "@bitwarden/key-management/angular";
|
||||
import { BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { LockComponentService, UnlockOptions } from "@bitwarden/key-management/angular";
|
||||
|
||||
export class DesktopLockComponentService implements LockComponentService {
|
||||
private readonly userDecryptionOptionsService = inject(UserDecryptionOptionsServiceAbstraction);
|
||||
private readonly platformUtilsService = inject(PlatformUtilsService);
|
||||
private readonly biometricsService = inject(BiometricsService);
|
||||
private readonly pinService = inject(PinServiceAbstraction);
|
||||
private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService);
|
||||
private readonly keyService = inject(KeyService);
|
||||
|
||||
constructor() {}
|
||||
|
||||
@@ -52,77 +44,29 @@ export class DesktopLockComponentService implements LockComponentService {
|
||||
}
|
||||
}
|
||||
|
||||
private async isBiometricLockSet(userId: UserId): Promise<boolean> {
|
||||
const biometricLockSet = await this.vaultTimeoutSettingsService.isBiometricLockSet(userId);
|
||||
const hasBiometricEncryptedUserKeyStored = await this.keyService.hasUserKeyStored(
|
||||
KeySuffixOptions.Biometric,
|
||||
userId,
|
||||
);
|
||||
const platformSupportsSecureStorage = this.platformUtilsService.supportsSecureStorage();
|
||||
|
||||
return (
|
||||
biometricLockSet && (hasBiometricEncryptedUserKeyStored || !platformSupportsSecureStorage)
|
||||
);
|
||||
}
|
||||
|
||||
private async isBiometricsSupportedAndReady(
|
||||
userId: UserId,
|
||||
): Promise<{ supportsBiometric: boolean; biometricReady: boolean }> {
|
||||
const supportsBiometric = await this.biometricsService.supportsBiometric();
|
||||
const biometricReady = await ipc.keyManagement.biometric.enabled(userId);
|
||||
return { supportsBiometric, biometricReady };
|
||||
}
|
||||
|
||||
getAvailableUnlockOptions$(userId: UserId): Observable<UnlockOptions> {
|
||||
return combineLatest([
|
||||
// Note: defer is preferable b/c it delays the execution of the function until the observable is subscribed to
|
||||
defer(() => this.isBiometricsSupportedAndReady(userId)),
|
||||
defer(() => this.isBiometricLockSet(userId)),
|
||||
defer(() => this.biometricsService.getBiometricsStatusForUser(userId)),
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
defer(() => this.pinService.isPinDecryptionAvailable(userId)),
|
||||
]).pipe(
|
||||
map(
|
||||
([biometricsData, isBiometricsLockSet, userDecryptionOptions, pinDecryptionAvailable]) => {
|
||||
const disableReason = this.getBiometricsDisabledReason(
|
||||
biometricsData.supportsBiometric,
|
||||
isBiometricsLockSet,
|
||||
biometricsData.biometricReady,
|
||||
);
|
||||
map(([biometricsStatus, userDecryptionOptions, pinDecryptionAvailable]) => {
|
||||
const unlockOpts: UnlockOptions = {
|
||||
masterPassword: {
|
||||
enabled: userDecryptionOptions.hasMasterPassword,
|
||||
},
|
||||
pin: {
|
||||
enabled: pinDecryptionAvailable,
|
||||
},
|
||||
biometrics: {
|
||||
enabled: biometricsStatus == BiometricsStatus.Available,
|
||||
biometricsStatus: biometricsStatus,
|
||||
},
|
||||
};
|
||||
|
||||
const unlockOpts: UnlockOptions = {
|
||||
masterPassword: {
|
||||
enabled: userDecryptionOptions.hasMasterPassword,
|
||||
},
|
||||
pin: {
|
||||
enabled: pinDecryptionAvailable,
|
||||
},
|
||||
biometrics: {
|
||||
enabled:
|
||||
biometricsData.supportsBiometric &&
|
||||
isBiometricsLockSet &&
|
||||
biometricsData.biometricReady,
|
||||
disableReason: disableReason,
|
||||
},
|
||||
};
|
||||
|
||||
return unlockOpts;
|
||||
},
|
||||
),
|
||||
return unlockOpts;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private getBiometricsDisabledReason(
|
||||
osSupportsBiometric: boolean,
|
||||
biometricLockSet: boolean,
|
||||
biometricReady: boolean,
|
||||
): BiometricsDisableReason | null {
|
||||
if (!osSupportsBiometric) {
|
||||
return BiometricsDisableReason.NotSupportedOnOperatingSystem;
|
||||
} else if (!biometricLockSet) {
|
||||
return BiometricsDisableReason.EncryptedKeysUnavailable;
|
||||
} else if (!biometricReady) {
|
||||
return BiometricsDisableReason.SystemBiometricsUnavailable;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,58 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
import { BiometricMessage, BiometricAction } from "../types/biometric-message";
|
||||
|
||||
const biometric = {
|
||||
enabled: (userId: string): Promise<boolean> =>
|
||||
authenticateWithBiometrics: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.EnabledForUser,
|
||||
key: `${userId}_user_biometric`,
|
||||
keySuffix: KeySuffixOptions.Biometric,
|
||||
action: BiometricAction.Authenticate,
|
||||
} satisfies BiometricMessage),
|
||||
getBiometricsStatus: (): Promise<BiometricsStatus> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.GetStatus,
|
||||
} satisfies BiometricMessage),
|
||||
unlockWithBiometricsForUser: (userId: string): Promise<UserKey | null> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.UnlockForUser,
|
||||
userId: userId,
|
||||
} satisfies BiometricMessage),
|
||||
osSupported: (): Promise<boolean> =>
|
||||
getBiometricsStatusForUser: (userId: string): Promise<BiometricsStatus> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.OsSupported,
|
||||
action: BiometricAction.GetStatusForUser,
|
||||
userId: userId,
|
||||
} satisfies BiometricMessage),
|
||||
biometricsNeedsSetup: (): Promise<boolean> =>
|
||||
setBiometricProtectedUnlockKeyForUser: (userId: string, value: string): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.NeedsSetup,
|
||||
action: BiometricAction.SetKeyForUser,
|
||||
userId: userId,
|
||||
key: value,
|
||||
} satisfies BiometricMessage),
|
||||
biometricsSetup: (): Promise<void> =>
|
||||
deleteBiometricUnlockKeyForUser: (userId: string): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.RemoveKeyForUser,
|
||||
userId: userId,
|
||||
} satisfies BiometricMessage),
|
||||
setupBiometrics: (): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.Setup,
|
||||
} satisfies BiometricMessage),
|
||||
biometricsCanAutoSetup: (): Promise<boolean> =>
|
||||
setClientKeyHalf: (userId: string, value: string): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.CanAutoSetup,
|
||||
action: BiometricAction.SetClientKeyHalf,
|
||||
userId: userId,
|
||||
key: value,
|
||||
} satisfies BiometricMessage),
|
||||
authenticate: (): Promise<boolean> =>
|
||||
getShouldAutoprompt: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.Authenticate,
|
||||
action: BiometricAction.GetShouldAutoprompt,
|
||||
} satisfies BiometricMessage),
|
||||
setShouldAutoprompt: (should: boolean): Promise<void> =>
|
||||
ipcRenderer.invoke("biometric", {
|
||||
action: BiometricAction.SetShouldAutoprompt,
|
||||
data: should,
|
||||
} satisfies BiometricMessage),
|
||||
};
|
||||
|
||||
|
||||
@@ -249,6 +249,20 @@
|
||||
"error": {
|
||||
"message": "Error"
|
||||
},
|
||||
"decryptionError": {
|
||||
"message": "Decryption error"
|
||||
},
|
||||
"couldNotDecryptVaultItemsBelow": {
|
||||
"message": "Bitwarden could not decrypt the vault item(s) listed below."
|
||||
},
|
||||
"contactCSToAvoidDataLossPart1": {
|
||||
"message": "Contact customer success",
|
||||
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
|
||||
},
|
||||
"contactCSToAvoidDataLossPart2": {
|
||||
"message": "to avoid additional data loss.",
|
||||
"description": "This is part of a larger sentence. The full sentence will read 'Contact customer success to avoid additional data loss.'"
|
||||
},
|
||||
"january": {
|
||||
"message": "January"
|
||||
},
|
||||
@@ -3362,6 +3376,30 @@
|
||||
"ssoError": {
|
||||
"message": "No free ports could be found for the sso login."
|
||||
},
|
||||
"biometricsStatusHelptextUnlockNeeded": {
|
||||
"message": "Biometric unlock is unavailable because PIN or password unlock is required first."
|
||||
},
|
||||
"biometricsStatusHelptextHardwareUnavailable": {
|
||||
"message": "Biometric unlock is currently unavailable."
|
||||
},
|
||||
"biometricsStatusHelptextAutoSetupNeeded": {
|
||||
"message": "Biometric unlock is unavailable due to misconfigured system files."
|
||||
},
|
||||
"biometricsStatusHelptextManualSetupNeeded": {
|
||||
"message": "Biometric unlock is unavailable due to misconfigured system files."
|
||||
},
|
||||
"biometricsStatusHelptextNotEnabledLocally": {
|
||||
"message": "Biometric unlock is unavailable because it is not enabled for $EMAIL$ in the Bitwarden desktop app.",
|
||||
"placeholders": {
|
||||
"email": {
|
||||
"content": "$1",
|
||||
"example": "mail@example.com"
|
||||
}
|
||||
}
|
||||
},
|
||||
"biometricsStatusHelptextUnavailableReasonUnknown": {
|
||||
"message": "Biometric unlock is currently unavailable for an unknown reason."
|
||||
},
|
||||
"authorize": {
|
||||
"message": "Authorize"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import * as path from "path";
|
||||
|
||||
import { app, ipcMain } from "electron";
|
||||
import { app } from "electron";
|
||||
import { Subject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
|
||||
@@ -28,8 +28,9 @@ import { DefaultBiometricStateService } from "@bitwarden/key-management";
|
||||
/* eslint-enable import/no-restricted-paths */
|
||||
|
||||
import { DesktopAutofillSettingsService } from "./autofill/services/desktop-autofill-settings.service";
|
||||
import { BiometricsRendererIPCListener } from "./key-management/biometrics/biometric.renderer-ipc.listener";
|
||||
import { BiometricsService, DesktopBiometricsService } from "./key-management/biometrics/index";
|
||||
import { DesktopBiometricsService } from "./key-management/biometrics/desktop.biometrics.service";
|
||||
import { MainBiometricsIPCListener } from "./key-management/biometrics/main-biometrics-ipc.listener";
|
||||
import { MainBiometricsService } from "./key-management/biometrics/main-biometrics.service";
|
||||
import { MenuMain } from "./main/menu/menu.main";
|
||||
import { MessagingMain } from "./main/messaging.main";
|
||||
import { NativeMessagingMain } from "./main/native-messaging.main";
|
||||
@@ -61,7 +62,7 @@ export class Main {
|
||||
messagingService: MessageSender;
|
||||
environmentService: DefaultEnvironmentService;
|
||||
desktopCredentialStorageListener: DesktopCredentialStorageListener;
|
||||
biometricsRendererIPCListener: BiometricsRendererIPCListener;
|
||||
mainBiometricsIpcListener: MainBiometricsIPCListener;
|
||||
desktopSettingsService: DesktopSettingsService;
|
||||
mainCryptoFunctionService: MainCryptoFunctionService;
|
||||
migrationRunner: MigrationRunner;
|
||||
@@ -177,6 +178,15 @@ export class Main {
|
||||
this.desktopSettingsService = new DesktopSettingsService(stateProvider);
|
||||
const biometricStateService = new DefaultBiometricStateService(stateProvider);
|
||||
|
||||
this.biometricsService = new MainBiometricsService(
|
||||
this.i18nService,
|
||||
this.windowMain,
|
||||
this.logService,
|
||||
this.messagingService,
|
||||
process.platform,
|
||||
biometricStateService,
|
||||
);
|
||||
|
||||
this.windowMain = new WindowMain(
|
||||
biometricStateService,
|
||||
this.logService,
|
||||
@@ -187,7 +197,6 @@ export class Main {
|
||||
);
|
||||
this.messagingMain = new MessagingMain(this, this.desktopSettingsService);
|
||||
this.updaterMain = new UpdaterMain(this.i18nService, this.windowMain);
|
||||
this.trayMain = new TrayMain(this.windowMain, this.i18nService, this.desktopSettingsService);
|
||||
|
||||
const messageSubject = new Subject<Message<Record<string, unknown>>>();
|
||||
this.messagingService = MessageSender.combine(
|
||||
@@ -218,22 +227,19 @@ export class Main {
|
||||
this.versionMain,
|
||||
);
|
||||
|
||||
this.biometricsService = new BiometricsService(
|
||||
this.i18nService,
|
||||
this.trayMain = new TrayMain(
|
||||
this.windowMain,
|
||||
this.logService,
|
||||
this.messagingService,
|
||||
process.platform,
|
||||
this.i18nService,
|
||||
this.desktopSettingsService,
|
||||
biometricStateService,
|
||||
this.biometricsService,
|
||||
);
|
||||
|
||||
this.desktopCredentialStorageListener = new DesktopCredentialStorageListener(
|
||||
"Bitwarden",
|
||||
this.biometricsService,
|
||||
this.logService,
|
||||
);
|
||||
this.biometricsRendererIPCListener = new BiometricsRendererIPCListener(
|
||||
"Bitwarden",
|
||||
this.mainBiometricsIpcListener = new MainBiometricsIPCListener(
|
||||
this.biometricsService,
|
||||
this.logService,
|
||||
);
|
||||
@@ -251,12 +257,7 @@ export class Main {
|
||||
this.clipboardMain = new ClipboardMain();
|
||||
this.clipboardMain.init();
|
||||
|
||||
ipcMain.handle("sshagent.init", async (event: any, message: any) => {
|
||||
if (this.sshAgentService == null) {
|
||||
this.sshAgentService = new MainSshAgentService(this.logService, this.messagingService);
|
||||
this.sshAgentService.init();
|
||||
}
|
||||
});
|
||||
this.sshAgentService = new MainSshAgentService(this.logService, this.messagingService);
|
||||
|
||||
new EphemeralValueStorageService();
|
||||
new SSOLocalhostCallbackService(this.environmentService, this.messagingService);
|
||||
@@ -267,7 +268,7 @@ export class Main {
|
||||
|
||||
bootstrap() {
|
||||
this.desktopCredentialStorageListener.init();
|
||||
this.biometricsRendererIPCListener.init();
|
||||
this.mainBiometricsIpcListener.init();
|
||||
// Run migrations first, then other things
|
||||
this.migrationRunner.run().then(
|
||||
async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { BiometricStateService, BiometricsService } from "@bitwarden/key-management";
|
||||
|
||||
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
|
||||
|
||||
@@ -23,6 +24,8 @@ export class TrayMain {
|
||||
private windowMain: WindowMain,
|
||||
private i18nService: I18nService,
|
||||
private desktopSettingsService: DesktopSettingsService,
|
||||
private biometricsStateService: BiometricStateService,
|
||||
private biometricService: BiometricsService,
|
||||
) {
|
||||
if (process.platform === "win32") {
|
||||
this.icon = path.join(__dirname, "/images/icon.ico");
|
||||
@@ -72,6 +75,10 @@ export class TrayMain {
|
||||
}
|
||||
});
|
||||
|
||||
win.on("restore", async () => {
|
||||
await this.biometricService.setShouldAutopromptNow(true);
|
||||
});
|
||||
|
||||
win.on("close", async (e: Event) => {
|
||||
if (await firstValueFrom(this.desktopSettingsService.closeToTray$)) {
|
||||
if (!this.windowMain.isQuitting) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export type LegacyMessage = {
|
||||
command: string;
|
||||
messageId: number;
|
||||
|
||||
userId?: string;
|
||||
timestamp?: number;
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
// @ts-strict-ignore
|
||||
import { ipcMain } from "electron";
|
||||
|
||||
import { BiometricKey } from "@bitwarden/common/auth/types/biometric-key";
|
||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
import { passwords } from "@bitwarden/desktop-napi";
|
||||
|
||||
import { DesktopBiometricsService } from "../../key-management/biometrics/index";
|
||||
|
||||
const AuthRequiredSuffix = "_biometric";
|
||||
|
||||
export class DesktopCredentialStorageListener {
|
||||
constructor(
|
||||
private serviceName: string,
|
||||
private biometricService: DesktopBiometricsService,
|
||||
private logService: ConsoleLogService,
|
||||
) {}
|
||||
|
||||
@@ -54,13 +48,7 @@ export class DesktopCredentialStorageListener {
|
||||
|
||||
// Gracefully handle old keytar values, and if detected updated the entry to the proper format
|
||||
private async getPassword(serviceName: string, key: string, keySuffix: string) {
|
||||
let val: string;
|
||||
// todo: remove this when biometrics has been migrated to desktop_native
|
||||
if (keySuffix === AuthRequiredSuffix) {
|
||||
val = (await this.biometricService.getBiometricKey(serviceName, key)) ?? null;
|
||||
} else {
|
||||
val = await passwords.getPassword(serviceName, key);
|
||||
}
|
||||
const val = await passwords.getPassword(serviceName, key);
|
||||
|
||||
try {
|
||||
JSON.parse(val);
|
||||
@@ -72,25 +60,10 @@ export class DesktopCredentialStorageListener {
|
||||
}
|
||||
|
||||
private async setPassword(serviceName: string, key: string, value: string, keySuffix: string) {
|
||||
if (keySuffix === AuthRequiredSuffix) {
|
||||
const valueObj = JSON.parse(value) as BiometricKey;
|
||||
await this.biometricService.setEncryptionKeyHalf({
|
||||
service: serviceName,
|
||||
key,
|
||||
value: valueObj?.clientEncKeyHalf,
|
||||
});
|
||||
// Value is usually a JSON string, but we need to pass the key half as well, so we re-stringify key here.
|
||||
await this.biometricService.setBiometricKey(serviceName, key, JSON.stringify(valueObj?.key));
|
||||
} else {
|
||||
await passwords.setPassword(serviceName, key, value);
|
||||
}
|
||||
await passwords.setPassword(serviceName, key, value);
|
||||
}
|
||||
|
||||
private async deletePassword(serviceName: string, key: string, keySuffix: string) {
|
||||
if (keySuffix === AuthRequiredSuffix) {
|
||||
await this.biometricService.deleteBiometricKey(serviceName, key);
|
||||
} else {
|
||||
await passwords.deletePassword(serviceName, key);
|
||||
}
|
||||
await passwords.deletePassword(serviceName, key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,25 @@ export class MainSshAgentService {
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private messagingService: MessagingService,
|
||||
) {}
|
||||
) {
|
||||
ipcMain.handle(
|
||||
"sshagent.importkey",
|
||||
async (
|
||||
event: any,
|
||||
{ privateKey, password }: { privateKey: string; password?: string },
|
||||
): Promise<sshagent.SshKeyImportResult> => {
|
||||
return sshagent.importKey(privateKey, password);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("sshagent.init", async (event: any, message: any) => {
|
||||
this.init();
|
||||
});
|
||||
|
||||
ipcMain.handle("sshagent.isloaded", async (event: any) => {
|
||||
return this.agentState != null;
|
||||
});
|
||||
}
|
||||
|
||||
init() {
|
||||
// handle sign request passing to UI
|
||||
@@ -94,21 +112,6 @@ export class MainSshAgentService {
|
||||
this.requestResponses.push({ requestId, accepted, timestamp: new Date() });
|
||||
},
|
||||
);
|
||||
ipcMain.handle(
|
||||
"sshagent.generatekey",
|
||||
async (event: any, { keyAlgorithm }: { keyAlgorithm: string }): Promise<sshagent.SshKey> => {
|
||||
return await sshagent.generateKeypair(keyAlgorithm);
|
||||
},
|
||||
);
|
||||
ipcMain.handle(
|
||||
"sshagent.importkey",
|
||||
async (
|
||||
event: any,
|
||||
{ privateKey, password }: { privateKey: string; password?: string },
|
||||
): Promise<sshagent.SshKeyImportResult> => {
|
||||
return sshagent.importKey(privateKey, password);
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle("sshagent.lock", async (event: any) => {
|
||||
if (this.agentState != null && (await sshagent.isRunning(this.agentState))) {
|
||||
|
||||
@@ -58,9 +58,6 @@ const sshAgent = {
|
||||
signRequestResponse: async (requestId: number, accepted: boolean) => {
|
||||
await ipcRenderer.invoke("sshagent.signrequestresponse", { requestId, accepted });
|
||||
},
|
||||
generateKey: async (keyAlgorithm: string): Promise<ssh.SshKey> => {
|
||||
return await ipcRenderer.invoke("sshagent.generatekey", { keyAlgorithm });
|
||||
},
|
||||
lock: async () => {
|
||||
return await ipcRenderer.invoke("sshagent.lock");
|
||||
},
|
||||
@@ -74,6 +71,9 @@ const sshAgent = {
|
||||
});
|
||||
return res;
|
||||
},
|
||||
isLoaded(): Promise<boolean> {
|
||||
return ipcRenderer.invoke("sshagent.isloaded");
|
||||
},
|
||||
};
|
||||
|
||||
const powermonitor = {
|
||||
@@ -87,6 +87,7 @@ const nativeMessaging = {
|
||||
},
|
||||
sendMessage: (message: {
|
||||
appId: string;
|
||||
messageId?: number;
|
||||
command?: string;
|
||||
sharedSecret?: string;
|
||||
message?: EncString;
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/auth/services/master-password/fake-master-password.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { makeEncString } from "@bitwarden/common/spec";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { KdfConfigService, BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import {
|
||||
FakeAccountService,
|
||||
mockAccountServiceWith,
|
||||
} from "../../../../../libs/common/spec/fake-account-service";
|
||||
|
||||
import { ElectronKeyService } from "./electron-key.service";
|
||||
|
||||
describe("electronKeyService", () => {
|
||||
let sut: ElectronKeyService;
|
||||
|
||||
const pinService = mock<PinServiceAbstraction>();
|
||||
const keyGenerationService = mock<KeyGenerationService>();
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
const platformUtilService = mock<PlatformUtilsService>();
|
||||
const logService = mock<LogService>();
|
||||
const stateService = mock<StateService>();
|
||||
let masterPasswordService: FakeMasterPasswordService;
|
||||
let accountService: FakeAccountService;
|
||||
let stateProvider: FakeStateProvider;
|
||||
const biometricStateService = mock<BiometricStateService>();
|
||||
const kdfConfigService = mock<KdfConfigService>();
|
||||
|
||||
const mockUserId = "mock user id" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith("userId" as UserId);
|
||||
masterPasswordService = new FakeMasterPasswordService();
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
sut = new ElectronKeyService(
|
||||
pinService,
|
||||
masterPasswordService,
|
||||
keyGenerationService,
|
||||
cryptoFunctionService,
|
||||
encryptService,
|
||||
platformUtilService,
|
||||
logService,
|
||||
stateService,
|
||||
accountService,
|
||||
stateProvider,
|
||||
biometricStateService,
|
||||
kdfConfigService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("setUserKey", () => {
|
||||
let mockUserKey: UserKey;
|
||||
|
||||
beforeEach(() => {
|
||||
const mockRandomBytes = new Uint8Array(64) as CsprngArray;
|
||||
mockUserKey = new SymmetricCryptoKey(mockRandomBytes) as UserKey;
|
||||
});
|
||||
|
||||
describe("Biometric Key refresh", () => {
|
||||
const encClientKeyHalf = makeEncString();
|
||||
const decClientKeyHalf = "decrypted client key half";
|
||||
|
||||
beforeEach(() => {
|
||||
encClientKeyHalf.decrypt = jest.fn().mockResolvedValue(decClientKeyHalf);
|
||||
});
|
||||
|
||||
it("sets a Biometric key if getBiometricUnlock is true and the platform supports secure storage", async () => {
|
||||
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
|
||||
platformUtilService.supportsSecureStorage.mockReturnValue(true);
|
||||
biometricStateService.getRequirePasswordOnStart.mockResolvedValue(true);
|
||||
biometricStateService.getEncryptedClientKeyHalf.mockResolvedValue(encClientKeyHalf);
|
||||
|
||||
await sut.setUserKey(mockUserKey, mockUserId);
|
||||
|
||||
expect(stateService.setUserKeyBiometric).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: expect.any(String), clientEncKeyHalf: decClientKeyHalf }),
|
||||
{
|
||||
userId: mockUserId,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the Biometric key if getBiometricUnlock is false or the platform does not support secure storage", async () => {
|
||||
biometricStateService.getBiometricUnlockEnabled.mockResolvedValue(true);
|
||||
platformUtilService.supportsSecureStorage.mockReturnValue(false);
|
||||
|
||||
await sut.setUserKey(mockUserKey, mockUserId);
|
||||
|
||||
expect(stateService.setUserKeyBiometric).toHaveBeenCalledWith(null, {
|
||||
userId: mockUserId,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { PinServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
|
||||
@@ -13,7 +11,6 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { CsprngString } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -24,6 +21,8 @@ import {
|
||||
BiometricStateService,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { DesktopBiometricsService } from "../../key-management/biometrics/desktop.biometrics.service";
|
||||
|
||||
export class ElectronKeyService extends DefaultKeyService {
|
||||
constructor(
|
||||
pinService: PinServiceAbstraction,
|
||||
@@ -38,6 +37,7 @@ export class ElectronKeyService extends DefaultKeyService {
|
||||
stateProvider: StateProvider,
|
||||
private biometricStateService: BiometricStateService,
|
||||
kdfConfigService: KdfConfigService,
|
||||
private biometricService: DesktopBiometricsService,
|
||||
) {
|
||||
super(
|
||||
pinService,
|
||||
@@ -55,19 +55,10 @@ export class ElectronKeyService extends DefaultKeyService {
|
||||
}
|
||||
|
||||
override async hasUserKeyStored(keySuffix: KeySuffixOptions, userId?: UserId): Promise<boolean> {
|
||||
if (keySuffix === KeySuffixOptions.Biometric) {
|
||||
return await this.stateService.hasUserKeyBiometric({ userId: userId });
|
||||
}
|
||||
return super.hasUserKeyStored(keySuffix, userId);
|
||||
}
|
||||
|
||||
override async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise<void> {
|
||||
if (keySuffix === KeySuffixOptions.Biometric) {
|
||||
await this.stateService.setUserKeyBiometric(null, { userId: userId });
|
||||
await this.biometricStateService.removeEncryptedClientKeyHalf(userId);
|
||||
await this.clearDeprecatedKeys(KeySuffixOptions.Biometric, userId);
|
||||
return;
|
||||
}
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
await super.clearStoredUserKey(keySuffix, userId);
|
||||
@@ -76,52 +67,35 @@ export class ElectronKeyService extends DefaultKeyService {
|
||||
protected override async storeAdditionalKeys(key: UserKey, userId: UserId) {
|
||||
await super.storeAdditionalKeys(key, userId);
|
||||
|
||||
const storeBiometricKey = await this.shouldStoreKey(KeySuffixOptions.Biometric, userId);
|
||||
|
||||
if (storeBiometricKey) {
|
||||
await this.storeBiometricKey(key, userId);
|
||||
} else {
|
||||
await this.stateService.setUserKeyBiometric(null, { userId: userId });
|
||||
if (await this.biometricStateService.getBiometricUnlockEnabled(userId)) {
|
||||
await this.storeBiometricsProtectedUserKey(key, userId);
|
||||
}
|
||||
await this.clearDeprecatedKeys(KeySuffixOptions.Biometric, userId);
|
||||
}
|
||||
|
||||
protected override async getKeyFromStorage(
|
||||
keySuffix: KeySuffixOptions,
|
||||
userId?: UserId,
|
||||
): Promise<UserKey> {
|
||||
if (keySuffix === KeySuffixOptions.Biometric) {
|
||||
const userKey = await this.stateService.getUserKeyBiometric({ userId: userId });
|
||||
return userKey == null
|
||||
? null
|
||||
: (new SymmetricCryptoKey(Utils.fromB64ToArray(userKey)) as UserKey);
|
||||
}
|
||||
return await super.getKeyFromStorage(keySuffix, userId);
|
||||
}
|
||||
|
||||
protected async storeBiometricKey(key: UserKey, userId?: UserId): Promise<void> {
|
||||
protected async storeBiometricsProtectedUserKey(
|
||||
userKey: UserKey,
|
||||
userId?: UserId,
|
||||
): Promise<void> {
|
||||
// May resolve to null, in which case no client key have is required
|
||||
const clientEncKeyHalf = await this.getBiometricEncryptionClientKeyHalf(key, userId);
|
||||
await this.stateService.setUserKeyBiometric(
|
||||
{ key: key.keyB64, clientEncKeyHalf },
|
||||
{ userId: userId },
|
||||
);
|
||||
// TODO: Move to windows implementation
|
||||
const clientEncKeyHalf = await this.getBiometricEncryptionClientKeyHalf(userKey, userId);
|
||||
await this.biometricService.setClientKeyHalfForUser(userId, clientEncKeyHalf);
|
||||
await this.biometricService.setBiometricProtectedUnlockKeyForUser(userId, userKey.keyB64);
|
||||
}
|
||||
|
||||
protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise<boolean> {
|
||||
if (keySuffix === KeySuffixOptions.Biometric) {
|
||||
const biometricUnlockPromise =
|
||||
userId == null
|
||||
? firstValueFrom(this.biometricStateService.biometricUnlockEnabled$)
|
||||
: this.biometricStateService.getBiometricUnlockEnabled(userId);
|
||||
const biometricUnlock = await biometricUnlockPromise;
|
||||
return biometricUnlock && this.platformUtilService.supportsSecureStorage();
|
||||
}
|
||||
return await super.shouldStoreKey(keySuffix, userId);
|
||||
}
|
||||
|
||||
protected override async clearAllStoredUserKeys(userId?: UserId): Promise<void> {
|
||||
await this.clearStoredUserKey(KeySuffixOptions.Biometric, userId);
|
||||
await this.biometricService.deleteBiometricUnlockKeyForUser(userId);
|
||||
await super.clearAllStoredUserKeys(userId);
|
||||
}
|
||||
|
||||
@@ -135,18 +109,18 @@ export class ElectronKeyService extends DefaultKeyService {
|
||||
}
|
||||
|
||||
// Retrieve existing key half if it exists
|
||||
let biometricKey = await this.biometricStateService
|
||||
let clientKeyHalf = await this.biometricStateService
|
||||
.getEncryptedClientKeyHalf(userId)
|
||||
.then((result) => result?.decrypt(null /* user encrypted */, userKey))
|
||||
.then((result) => result as CsprngString);
|
||||
if (biometricKey == null && userKey != null) {
|
||||
if (clientKeyHalf == null && userKey != null) {
|
||||
// Set a key half if it doesn't exist
|
||||
const keyBytes = await this.cryptoFunctionService.randomBytes(32);
|
||||
biometricKey = Utils.fromBufferToUtf8(keyBytes) as CsprngString;
|
||||
const encKey = await this.encryptService.encrypt(biometricKey, userKey);
|
||||
clientKeyHalf = Utils.fromBufferToUtf8(keyBytes) as CsprngString;
|
||||
const encKey = await this.encryptService.encrypt(clientKeyHalf, userKey);
|
||||
await this.biometricStateService.setEncryptedClientKeyHalf(encKey, userId);
|
||||
}
|
||||
|
||||
return biometricKey;
|
||||
return clientKeyHalf;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,153 +61,87 @@ export class SshAgentService implements OnDestroy {
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
const isSshAgentFeatureEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHAgent);
|
||||
if (isSshAgentFeatureEnabled) {
|
||||
await ipc.platform.sshAgent.init();
|
||||
this.configService
|
||||
.getFeatureFlag$(FeatureFlag.SSHAgent)
|
||||
.pipe(
|
||||
concatMap(async (enabled) => {
|
||||
if (enabled && !(await ipc.platform.sshAgent.isLoaded())) {
|
||||
return this.initSshAgent();
|
||||
}
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
this.messageListener
|
||||
.messages$(new CommandDefinition("sshagent.signrequest"))
|
||||
.pipe(
|
||||
withLatestFrom(this.authService.activeAccountStatus$),
|
||||
// This switchMap handles unlocking the vault if it is locked:
|
||||
// - If the vault is locked, we will wait for it to be unlocked.
|
||||
// - If the vault is not unlocked within the timeout, we will abort the flow.
|
||||
// - If the vault is unlocked, we will continue with the flow.
|
||||
// switchMap is used here to prevent multiple requests from being processed at the same time,
|
||||
// and will cancel the previous request if a new one is received.
|
||||
switchMap(([message, status]) => {
|
||||
if (status !== AuthenticationStatus.Unlocked) {
|
||||
ipc.platform.focusWindow();
|
||||
this.toastService.showToast({
|
||||
variant: "info",
|
||||
title: null,
|
||||
message: this.i18nService.t("sshAgentUnlockRequired"),
|
||||
});
|
||||
return this.authService.activeAccountStatus$.pipe(
|
||||
filter((status) => status === AuthenticationStatus.Unlocked),
|
||||
timeout({
|
||||
first: this.SSH_VAULT_UNLOCK_REQUEST_TIMEOUT,
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
if (error instanceof TimeoutError) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("sshAgentUnlockTimeout"),
|
||||
});
|
||||
const requestId = message.requestId as number;
|
||||
// Abort flow by sending a false response.
|
||||
// Returning an empty observable this will prevent the rest of the flow from executing
|
||||
return from(ipc.platform.sshAgent.signRequestResponse(requestId, false)).pipe(
|
||||
map(() => EMPTY),
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}),
|
||||
map(() => message),
|
||||
);
|
||||
}
|
||||
|
||||
return of(message);
|
||||
}),
|
||||
// This switchMap handles fetching the ciphers from the vault.
|
||||
switchMap((message) =>
|
||||
from(this.cipherService.getAllDecrypted()).pipe(
|
||||
map((ciphers) => [message, ciphers] as const),
|
||||
),
|
||||
),
|
||||
// This concatMap handles showing the dialog to approve the request.
|
||||
concatMap(async ([message, ciphers]) => {
|
||||
const cipherId = message.cipherId as string;
|
||||
const isListRequest = message.isListRequest as boolean;
|
||||
const requestId = message.requestId as number;
|
||||
let application = message.processName as string;
|
||||
if (application == "") {
|
||||
application = this.i18nService.t("unknownApplication");
|
||||
}
|
||||
|
||||
if (isListRequest) {
|
||||
const sshCiphers = ciphers.filter(
|
||||
(cipher) => cipher.type === CipherType.SshKey && !cipher.isDeleted,
|
||||
);
|
||||
const keys = sshCiphers.map((cipher) => {
|
||||
return {
|
||||
name: cipher.name,
|
||||
privateKey: cipher.sshKey.privateKey,
|
||||
cipherId: cipher.id,
|
||||
};
|
||||
});
|
||||
await ipc.platform.sshAgent.setKeys(keys);
|
||||
await ipc.platform.sshAgent.signRequestResponse(requestId, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ciphers === undefined) {
|
||||
ipc.platform.sshAgent
|
||||
.signRequestResponse(requestId, false)
|
||||
.catch((e) => this.logService.error("Failed to respond to SSH request", e));
|
||||
}
|
||||
|
||||
const cipher = ciphers.find((cipher) => cipher.id == cipherId);
|
||||
private async initSshAgent() {
|
||||
await ipc.platform.sshAgent.init();
|
||||
|
||||
this.messageListener
|
||||
.messages$(new CommandDefinition("sshagent.signrequest"))
|
||||
.pipe(
|
||||
withLatestFrom(this.authService.activeAccountStatus$),
|
||||
// This switchMap handles unlocking the vault if it is locked:
|
||||
// - If the vault is locked, we will wait for it to be unlocked.
|
||||
// - If the vault is not unlocked within the timeout, we will abort the flow.
|
||||
// - If the vault is unlocked, we will continue with the flow.
|
||||
// switchMap is used here to prevent multiple requests from being processed at the same time,
|
||||
// and will cancel the previous request if a new one is received.
|
||||
switchMap(([message, status]) => {
|
||||
if (status !== AuthenticationStatus.Unlocked) {
|
||||
ipc.platform.focusWindow();
|
||||
const dialogRef = ApproveSshRequestComponent.open(
|
||||
this.dialogService,
|
||||
cipher.name,
|
||||
application,
|
||||
this.toastService.showToast({
|
||||
variant: "info",
|
||||
title: null,
|
||||
message: this.i18nService.t("sshAgentUnlockRequired"),
|
||||
});
|
||||
return this.authService.activeAccountStatus$.pipe(
|
||||
filter((status) => status === AuthenticationStatus.Unlocked),
|
||||
timeout({
|
||||
first: this.SSH_VAULT_UNLOCK_REQUEST_TIMEOUT,
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
if (error instanceof TimeoutError) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: null,
|
||||
message: this.i18nService.t("sshAgentUnlockTimeout"),
|
||||
});
|
||||
const requestId = message.requestId as number;
|
||||
// Abort flow by sending a false response.
|
||||
// Returning an empty observable this will prevent the rest of the flow from executing
|
||||
return from(ipc.platform.sshAgent.signRequestResponse(requestId, false)).pipe(
|
||||
map(() => EMPTY),
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}),
|
||||
map(() => message),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
return ipc.platform.sshAgent.signRequestResponse(requestId, result);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.accountService.activeAccount$.pipe(skip(1), takeUntil(this.destroy$)).subscribe({
|
||||
next: (account) => {
|
||||
this.logService.info("Active account changed, clearing SSH keys");
|
||||
ipc.platform.sshAgent
|
||||
.clearKeys()
|
||||
.catch((e) => this.logService.error("Failed to clear SSH keys", e));
|
||||
},
|
||||
error: (e: unknown) => {
|
||||
this.logService.error("Error in active account observable", e);
|
||||
ipc.platform.sshAgent
|
||||
.clearKeys()
|
||||
.catch((e) => this.logService.error("Failed to clear SSH keys", e));
|
||||
},
|
||||
complete: () => {
|
||||
this.logService.info("Active account observable completed, clearing SSH keys");
|
||||
ipc.platform.sshAgent
|
||||
.clearKeys()
|
||||
.catch((e) => this.logService.error("Failed to clear SSH keys", e));
|
||||
},
|
||||
});
|
||||
|
||||
combineLatest([
|
||||
timer(0, this.SSH_REFRESH_INTERVAL),
|
||||
this.desktopSettingsService.sshAgentEnabled$,
|
||||
])
|
||||
.pipe(
|
||||
concatMap(async ([, enabled]) => {
|
||||
if (!enabled) {
|
||||
await ipc.platform.sshAgent.clearKeys();
|
||||
return;
|
||||
}
|
||||
|
||||
const ciphers = await this.cipherService.getAllDecrypted();
|
||||
if (ciphers == null) {
|
||||
await ipc.platform.sshAgent.lock();
|
||||
return;
|
||||
}
|
||||
return of(message);
|
||||
}),
|
||||
// This switchMap handles fetching the ciphers from the vault.
|
||||
switchMap((message) =>
|
||||
from(this.cipherService.getAllDecrypted()).pipe(
|
||||
map((ciphers) => [message, ciphers] as const),
|
||||
),
|
||||
),
|
||||
// This concatMap handles showing the dialog to approve the request.
|
||||
concatMap(async ([message, ciphers]) => {
|
||||
const cipherId = message.cipherId as string;
|
||||
const isListRequest = message.isListRequest as boolean;
|
||||
const requestId = message.requestId as number;
|
||||
let application = message.processName as string;
|
||||
if (application == "") {
|
||||
application = this.i18nService.t("unknownApplication");
|
||||
}
|
||||
|
||||
if (isListRequest) {
|
||||
const sshCiphers = ciphers.filter(
|
||||
(cipher) =>
|
||||
cipher.type === CipherType.SshKey &&
|
||||
!cipher.isDeleted &&
|
||||
cipher.organizationId === null,
|
||||
(cipher) => cipher.type === CipherType.SshKey && !cipher.isDeleted,
|
||||
);
|
||||
const keys = sshCiphers.map((cipher) => {
|
||||
return {
|
||||
@@ -217,11 +151,88 @@ export class SshAgentService implements OnDestroy {
|
||||
};
|
||||
});
|
||||
await ipc.platform.sshAgent.setKeys(keys);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
await ipc.platform.sshAgent.signRequestResponse(requestId, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ciphers === undefined) {
|
||||
ipc.platform.sshAgent
|
||||
.signRequestResponse(requestId, false)
|
||||
.catch((e) => this.logService.error("Failed to respond to SSH request", e));
|
||||
}
|
||||
|
||||
const cipher = ciphers.find((cipher) => cipher.id == cipherId);
|
||||
|
||||
ipc.platform.focusWindow();
|
||||
const dialogRef = ApproveSshRequestComponent.open(
|
||||
this.dialogService,
|
||||
cipher.name,
|
||||
application,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
return ipc.platform.sshAgent.signRequestResponse(requestId, result);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.accountService.activeAccount$.pipe(skip(1), takeUntil(this.destroy$)).subscribe({
|
||||
next: (account) => {
|
||||
this.logService.info("Active account changed, clearing SSH keys");
|
||||
ipc.platform.sshAgent
|
||||
.clearKeys()
|
||||
.catch((e) => this.logService.error("Failed to clear SSH keys", e));
|
||||
},
|
||||
error: (e: unknown) => {
|
||||
this.logService.error("Error in active account observable", e);
|
||||
ipc.platform.sshAgent
|
||||
.clearKeys()
|
||||
.catch((e) => this.logService.error("Failed to clear SSH keys", e));
|
||||
},
|
||||
complete: () => {
|
||||
this.logService.info("Active account observable completed, clearing SSH keys");
|
||||
ipc.platform.sshAgent
|
||||
.clearKeys()
|
||||
.catch((e) => this.logService.error("Failed to clear SSH keys", e));
|
||||
},
|
||||
});
|
||||
|
||||
combineLatest([
|
||||
timer(0, this.SSH_REFRESH_INTERVAL),
|
||||
this.desktopSettingsService.sshAgentEnabled$,
|
||||
])
|
||||
.pipe(
|
||||
concatMap(async ([, enabled]) => {
|
||||
if (!enabled) {
|
||||
await ipc.platform.sshAgent.clearKeys();
|
||||
return;
|
||||
}
|
||||
|
||||
const ciphers = await this.cipherService.getAllDecrypted();
|
||||
if (ciphers == null) {
|
||||
await ipc.platform.sshAgent.lock();
|
||||
return;
|
||||
}
|
||||
|
||||
const sshCiphers = ciphers.filter(
|
||||
(cipher) =>
|
||||
cipher.type === CipherType.SshKey &&
|
||||
!cipher.isDeleted &&
|
||||
cipher.organizationId === null,
|
||||
);
|
||||
const keys = sshCiphers.map((cipher) => {
|
||||
return {
|
||||
name: cipher.name,
|
||||
privateKey: cipher.sshKey.privateKey,
|
||||
cipherId: cipher.id,
|
||||
};
|
||||
});
|
||||
await ipc.platform.sshAgent.setKeys(keys);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { NgZone } from "@angular/core";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { FakeAccountService } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { KeyService, BiometricsService, BiometricStateService } from "@bitwarden/key-management";
|
||||
|
||||
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
|
||||
|
||||
import { BiometricMessageHandlerService } from "./biometric-message-handler.service";
|
||||
|
||||
(global as any).ipc = {
|
||||
platform: {
|
||||
reloadProcess: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const SomeUser = "SomeUser" as UserId;
|
||||
const AnotherUser = "SomeOtherUser" as UserId;
|
||||
const accounts = {
|
||||
[SomeUser]: {
|
||||
name: "some user",
|
||||
email: "some.user@example.com",
|
||||
emailVerified: true,
|
||||
},
|
||||
[AnotherUser]: {
|
||||
name: "some other user",
|
||||
email: "some.other.user@example.com",
|
||||
emailVerified: true,
|
||||
},
|
||||
};
|
||||
|
||||
describe("BiometricMessageHandlerService", () => {
|
||||
let service: BiometricMessageHandlerService;
|
||||
|
||||
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let desktopSettingsService: DesktopSettingsService;
|
||||
let biometricStateService: BiometricStateService;
|
||||
let biometricsService: MockProxy<BiometricsService>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let accountService: AccountService;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let ngZone: MockProxy<NgZone>;
|
||||
|
||||
beforeEach(() => {
|
||||
cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
keyService = mock<KeyService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
logService = mock<LogService>();
|
||||
messagingService = mock<MessagingService>();
|
||||
desktopSettingsService = mock<DesktopSettingsService>();
|
||||
biometricStateService = mock<BiometricStateService>();
|
||||
biometricsService = mock<BiometricsService>();
|
||||
dialogService = mock<DialogService>();
|
||||
|
||||
accountService = new FakeAccountService(accounts);
|
||||
authService = mock<AuthService>();
|
||||
ngZone = mock<NgZone>();
|
||||
|
||||
service = new BiometricMessageHandlerService(
|
||||
cryptoFunctionService,
|
||||
keyService,
|
||||
encryptService,
|
||||
logService,
|
||||
messagingService,
|
||||
desktopSettingsService,
|
||||
biometricStateService,
|
||||
biometricsService,
|
||||
dialogService,
|
||||
accountService,
|
||||
authService,
|
||||
ngZone,
|
||||
);
|
||||
});
|
||||
|
||||
describe("process reload", () => {
|
||||
const testCases = [
|
||||
// don't reload when the active user is the requested one and unlocked
|
||||
[SomeUser, AuthenticationStatus.Unlocked, SomeUser, false, false],
|
||||
// do reload when the active user is the requested one but locked
|
||||
[SomeUser, AuthenticationStatus.Locked, SomeUser, false, true],
|
||||
// always reload when another user is active than the requested one
|
||||
[SomeUser, AuthenticationStatus.Unlocked, AnotherUser, false, true],
|
||||
[SomeUser, AuthenticationStatus.Locked, AnotherUser, false, true],
|
||||
|
||||
// don't reload in dev mode
|
||||
[SomeUser, AuthenticationStatus.Unlocked, SomeUser, true, false],
|
||||
[SomeUser, AuthenticationStatus.Locked, SomeUser, true, false],
|
||||
[SomeUser, AuthenticationStatus.Unlocked, AnotherUser, true, false],
|
||||
[SomeUser, AuthenticationStatus.Locked, AnotherUser, true, false],
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
"process reload for active user %s with auth status %s and other user %s and isdev: %s should process reload: %s",
|
||||
async (activeUser, authStatus, messageUser, isDev, shouldReload) => {
|
||||
await accountService.switchAccount(activeUser as UserId);
|
||||
authService.authStatusFor$.mockReturnValue(of(authStatus as AuthenticationStatus));
|
||||
(global as any).ipc.platform.isDev = isDev;
|
||||
(global as any).ipc.platform.reloadProcess.mockClear();
|
||||
await service.processReloadWhenRequired(messageUser as UserId);
|
||||
|
||||
if (shouldReload) {
|
||||
expect((global as any).ipc.platform.reloadProcess).toHaveBeenCalled();
|
||||
} else {
|
||||
expect((global as any).ipc.platform.reloadProcess).not.toHaveBeenCalled();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,13 +10,18 @@ import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/c
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { KeySuffixOptions } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { BiometricStateService, BiometricsService, KeyService } from "@bitwarden/key-management";
|
||||
import {
|
||||
BiometricStateService,
|
||||
BiometricsCommands,
|
||||
BiometricsService,
|
||||
BiometricsStatus,
|
||||
KeyService,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { BrowserSyncVerificationDialogComponent } from "../app/components/browser-sync-verification-dialog.component";
|
||||
import { LegacyMessage } from "../models/native-messaging/legacy-message";
|
||||
@@ -54,6 +59,9 @@ export class BiometricMessageHandlerService {
|
||||
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||
const userIds = Object.keys(accounts);
|
||||
if (!userIds.includes(rawMessage.userId)) {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Received message for user that is not logged into the desktop app.",
|
||||
);
|
||||
ipc.platform.nativeMessaging.sendMessage({
|
||||
command: "wrongUserId",
|
||||
appId: appId,
|
||||
@@ -62,6 +70,7 @@ export class BiometricMessageHandlerService {
|
||||
}
|
||||
|
||||
if (await firstValueFrom(this.desktopSettingService.browserIntegrationFingerprintEnabled$)) {
|
||||
this.logService.info("[Native Messaging IPC] Requesting fingerprint verification.");
|
||||
ipc.platform.nativeMessaging.sendMessage({
|
||||
command: "verifyFingerprint",
|
||||
appId: appId,
|
||||
@@ -81,6 +90,7 @@ export class BiometricMessageHandlerService {
|
||||
const browserSyncVerified = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (browserSyncVerified !== true) {
|
||||
this.logService.info("[Native Messaging IPC] Fingerprint verification failed.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -90,6 +100,9 @@ export class BiometricMessageHandlerService {
|
||||
}
|
||||
|
||||
if ((await ipc.platform.ephemeralStore.getEphemeralValue(appId)) == null) {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Epheremal secret for secure channel is missing. Invalidating encryption...",
|
||||
);
|
||||
ipc.platform.nativeMessaging.sendMessage({
|
||||
command: "invalidateEncryption",
|
||||
appId: appId,
|
||||
@@ -106,6 +119,9 @@ export class BiometricMessageHandlerService {
|
||||
|
||||
// Shared secret is invalidated, force re-authentication
|
||||
if (message == null) {
|
||||
this.logService.info(
|
||||
"[Native Messaging IPC] Secure channel failed to decrypt message. Invalidating encryption...",
|
||||
);
|
||||
ipc.platform.nativeMessaging.sendMessage({
|
||||
command: "invalidateEncryption",
|
||||
appId: appId,
|
||||
@@ -114,20 +130,86 @@ export class BiometricMessageHandlerService {
|
||||
}
|
||||
|
||||
if (Math.abs(message.timestamp - Date.now()) > MessageValidTimeout) {
|
||||
this.logService.error("NativeMessage is to old, ignoring.");
|
||||
this.logService.info("[Native Messaging IPC] Received a too old message. Ignoring.");
|
||||
return;
|
||||
}
|
||||
|
||||
const messageId = message.messageId;
|
||||
|
||||
switch (message.command) {
|
||||
case "biometricUnlock": {
|
||||
case BiometricsCommands.UnlockWithBiometricsForUser: {
|
||||
await this.handleUnlockWithBiometricsForUser(message, messageId, appId);
|
||||
break;
|
||||
}
|
||||
case BiometricsCommands.AuthenticateWithBiometrics: {
|
||||
try {
|
||||
const unlocked = await this.biometricsService.authenticateWithBiometrics();
|
||||
await this.send(
|
||||
{
|
||||
command: BiometricsCommands.AuthenticateWithBiometrics,
|
||||
messageId,
|
||||
response: unlocked,
|
||||
},
|
||||
appId,
|
||||
);
|
||||
} catch (e) {
|
||||
this.logService.error("[Native Messaging IPC] Biometric authentication failed", e);
|
||||
await this.send(
|
||||
{ command: BiometricsCommands.AuthenticateWithBiometrics, messageId, response: false },
|
||||
appId,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case BiometricsCommands.GetBiometricsStatus: {
|
||||
const status = await this.biometricsService.getBiometricsStatus();
|
||||
return this.send(
|
||||
{
|
||||
command: BiometricsCommands.GetBiometricsStatus,
|
||||
messageId,
|
||||
response: status,
|
||||
},
|
||||
appId,
|
||||
);
|
||||
}
|
||||
case BiometricsCommands.GetBiometricsStatusForUser: {
|
||||
let status = await this.biometricsService.getBiometricsStatusForUser(
|
||||
message.userId as UserId,
|
||||
);
|
||||
if (status == BiometricsStatus.NotEnabledLocally) {
|
||||
status = BiometricsStatus.NotEnabledInConnectedDesktopApp;
|
||||
}
|
||||
return this.send(
|
||||
{
|
||||
command: BiometricsCommands.GetBiometricsStatusForUser,
|
||||
messageId,
|
||||
response: status,
|
||||
},
|
||||
appId,
|
||||
);
|
||||
}
|
||||
// TODO: legacy, remove after 2025.01
|
||||
case BiometricsCommands.IsAvailable: {
|
||||
const available =
|
||||
(await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available;
|
||||
return this.send(
|
||||
{
|
||||
command: BiometricsCommands.IsAvailable,
|
||||
response: available ? "available" : "not available",
|
||||
},
|
||||
appId,
|
||||
);
|
||||
}
|
||||
// TODO: legacy, remove after 2025.01
|
||||
case BiometricsCommands.Unlock: {
|
||||
const isTemporarilyDisabled =
|
||||
(await this.biometricStateService.getBiometricUnlockEnabled(message.userId as UserId)) &&
|
||||
!(await this.biometricsService.supportsBiometric());
|
||||
!((await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available);
|
||||
if (isTemporarilyDisabled) {
|
||||
return this.send({ command: "biometricUnlock", response: "not available" }, appId);
|
||||
}
|
||||
|
||||
if (!(await this.biometricsService.supportsBiometric())) {
|
||||
if (!((await this.biometricsService.getBiometricsStatus()) == BiometricsStatus.Available)) {
|
||||
return this.send({ command: "biometricUnlock", response: "not supported" }, appId);
|
||||
}
|
||||
|
||||
@@ -158,10 +240,7 @@ export class BiometricMessageHandlerService {
|
||||
}
|
||||
|
||||
try {
|
||||
const userKey = await this.keyService.getUserKeyFromStorage(
|
||||
KeySuffixOptions.Biometric,
|
||||
message.userId,
|
||||
);
|
||||
const userKey = await this.biometricsService.unlockWithBiometricsForUser(userId);
|
||||
|
||||
if (userKey != null) {
|
||||
await this.send(
|
||||
@@ -189,19 +268,8 @@ export class BiometricMessageHandlerService {
|
||||
} catch (e) {
|
||||
await this.send({ command: "biometricUnlock", response: "canceled" }, appId);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "biometricUnlockAvailable": {
|
||||
const isAvailable = await this.biometricsService.supportsBiometric();
|
||||
return this.send(
|
||||
{
|
||||
command: "biometricUnlockAvailable",
|
||||
response: isAvailable ? "available" : "not available",
|
||||
},
|
||||
appId,
|
||||
);
|
||||
}
|
||||
default:
|
||||
this.logService.error("NativeMessage, got unknown command: " + message.command);
|
||||
break;
|
||||
@@ -216,7 +284,11 @@ export class BiometricMessageHandlerService {
|
||||
SymmetricCryptoKey.fromString(await ipc.platform.ephemeralStore.getEphemeralValue(appId)),
|
||||
);
|
||||
|
||||
ipc.platform.nativeMessaging.sendMessage({ appId: appId, message: encrypted });
|
||||
ipc.platform.nativeMessaging.sendMessage({
|
||||
appId: appId,
|
||||
messageId: message.messageId,
|
||||
message: encrypted,
|
||||
});
|
||||
}
|
||||
|
||||
private async secureCommunication(remotePublicKey: Uint8Array, appId: string) {
|
||||
@@ -226,6 +298,7 @@ export class BiometricMessageHandlerService {
|
||||
new SymmetricCryptoKey(secret).keyB64,
|
||||
);
|
||||
|
||||
this.logService.info("[Native Messaging IPC] Setting up secure channel");
|
||||
const encryptedSecret = await this.cryptoFunctionService.rsaEncrypt(
|
||||
secret,
|
||||
remotePublicKey,
|
||||
@@ -234,7 +307,62 @@ export class BiometricMessageHandlerService {
|
||||
ipc.platform.nativeMessaging.sendMessage({
|
||||
appId: appId,
|
||||
command: "setupEncryption",
|
||||
messageId: -1, // to indicate to the other side that this is a new desktop client. refactor later to use proper versioning
|
||||
sharedSecret: Utils.fromBufferToB64(encryptedSecret),
|
||||
});
|
||||
}
|
||||
|
||||
private async handleUnlockWithBiometricsForUser(
|
||||
message: LegacyMessage,
|
||||
messageId: number,
|
||||
appId: string,
|
||||
) {
|
||||
const messageUserId = message.userId as UserId;
|
||||
try {
|
||||
const userKey = await this.biometricsService.unlockWithBiometricsForUser(messageUserId);
|
||||
if (userKey != null) {
|
||||
this.logService.info("[Native Messaging IPC] Biometric unlock for user: " + messageUserId);
|
||||
await this.send(
|
||||
{
|
||||
command: BiometricsCommands.UnlockWithBiometricsForUser,
|
||||
response: true,
|
||||
messageId,
|
||||
userKeyB64: userKey.keyB64,
|
||||
},
|
||||
appId,
|
||||
);
|
||||
await this.processReloadWhenRequired(messageUserId);
|
||||
} else {
|
||||
await this.send(
|
||||
{
|
||||
command: BiometricsCommands.UnlockWithBiometricsForUser,
|
||||
messageId,
|
||||
response: false,
|
||||
},
|
||||
appId,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
await this.send(
|
||||
{ command: BiometricsCommands.UnlockWithBiometricsForUser, messageId, response: false },
|
||||
appId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** A process reload after a biometric unlock should happen if the userkey that was used for biometric unlock is for a different user than the
|
||||
* currently active account. The userkey for the active account was in memory anyways. Further, if the desktop app is locked, a reload should occur (since the userkey was not already in memory).
|
||||
*/
|
||||
async processReloadWhenRequired(messageUserId: UserId) {
|
||||
const currentlyActiveAccountId = (await firstValueFrom(this.accountService.activeAccount$)).id;
|
||||
const isCurrentlyActiveAccountUnlocked =
|
||||
(await firstValueFrom(this.authService.authStatusFor$(currentlyActiveAccountId))) ==
|
||||
AuthenticationStatus.Unlocked;
|
||||
|
||||
if (currentlyActiveAccountId !== messageUserId || !isCurrentlyActiveAccountUnlocked) {
|
||||
if (!ipc.platform.isDev) {
|
||||
ipc.platform.reloadProcess();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
export enum BiometricAction {
|
||||
EnabledForUser = "enabled",
|
||||
OsSupported = "osSupported",
|
||||
Authenticate = "authenticate",
|
||||
NeedsSetup = "needsSetup",
|
||||
GetStatus = "status",
|
||||
|
||||
UnlockForUser = "unlockForUser",
|
||||
GetStatusForUser = "statusForUser",
|
||||
SetKeyForUser = "setKeyForUser",
|
||||
RemoveKeyForUser = "removeKeyForUser",
|
||||
|
||||
SetClientKeyHalf = "setClientKeyHalf",
|
||||
|
||||
Setup = "setup",
|
||||
CanAutoSetup = "canAutoSetup",
|
||||
|
||||
GetShouldAutoprompt = "getShouldAutoprompt",
|
||||
SetShouldAutoprompt = "setShouldAutoprompt",
|
||||
}
|
||||
|
||||
export type BiometricMessage = {
|
||||
action: BiometricAction;
|
||||
keySuffix?: string;
|
||||
key?: string;
|
||||
userId?: string;
|
||||
data?: any;
|
||||
};
|
||||
|
||||
@@ -512,16 +512,6 @@
|
||||
[ngClass]="{ 'bwi-eye': !showPrivateKey, 'bwi-eye-slash': showPrivateKey }"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
appA11yTitle="{{ 'regenerateSshKey' | i18n }}"
|
||||
(click)="generateSshKey()"
|
||||
*ngIf="cipher.edit || !editMode"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-generate" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-flex" appBoxRow>
|
||||
|
||||
@@ -19,9 +19,9 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { SshKeyPasswordPromptComponent } from "@bitwarden/importer/ui";
|
||||
@@ -56,8 +56,9 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
dialogService: DialogService,
|
||||
datePipe: DatePipe,
|
||||
configService: ConfigService,
|
||||
private toastService: ToastService,
|
||||
toastService: ToastService,
|
||||
cipherAuthorizationService: CipherAuthorizationService,
|
||||
sdkService: SdkService,
|
||||
) {
|
||||
super(
|
||||
cipherService,
|
||||
@@ -78,6 +79,8 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
datePipe,
|
||||
configService,
|
||||
cipherAuthorizationService,
|
||||
toastService,
|
||||
sdkService,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,17 +117,6 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
}
|
||||
|
||||
await super.load();
|
||||
|
||||
if (!this.editMode || this.cloneMode) {
|
||||
// Creating an ssh key directly while filtering to the ssh key category
|
||||
// must force a key to be set. SSH keys must never be created with an empty private key field
|
||||
if (
|
||||
this.cipher.type === CipherType.SshKey &&
|
||||
(this.cipher.sshKey.privateKey == null || this.cipher.sshKey.privateKey === "")
|
||||
) {
|
||||
await this.generateSshKey(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onWindowHidden() {
|
||||
@@ -156,21 +148,6 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
);
|
||||
}
|
||||
|
||||
async generateSshKey(showNotification: boolean = true) {
|
||||
const sshKey = await ipc.platform.sshAgent.generateKey("ed25519");
|
||||
this.cipher.sshKey.privateKey = sshKey.privateKey;
|
||||
this.cipher.sshKey.publicKey = sshKey.publicKey;
|
||||
this.cipher.sshKey.keyFingerprint = sshKey.keyFingerprint;
|
||||
|
||||
if (showNotification) {
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("sshKeyGenerated"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async importSshKeyFromClipboard(password: string = "") {
|
||||
const key = await this.platformUtilsService.readFromClipboard();
|
||||
const parsedKey = await ipc.platform.sshAgent.importKey(key, password);
|
||||
@@ -234,12 +211,6 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
return await lastValueFrom(dialog.closed);
|
||||
}
|
||||
|
||||
async typeChange() {
|
||||
if (this.cipher.type === CipherType.SshKey) {
|
||||
await this.generateSshKey();
|
||||
}
|
||||
}
|
||||
|
||||
truncateString(value: string, length: number) {
|
||||
return value.length > length ? value.substring(0, length) + "..." : value;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
ViewContainerRef,
|
||||
} from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { Subject, takeUntil, switchMap } from "rxjs";
|
||||
import { first } from "rxjs/operators";
|
||||
import { combineLatest, firstValueFrom, Subject, takeUntil, switchMap } from "rxjs";
|
||||
import { filter, first, map, take } from "rxjs/operators";
|
||||
|
||||
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
|
||||
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||
@@ -28,13 +28,15 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
import { DecryptionFailureDialogComponent, PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
import { SearchBarService } from "../../../app/layout/search/search-bar.service";
|
||||
import { GeneratorComponent } from "../../../app/tools/generator.component";
|
||||
@@ -113,6 +115,7 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private configService: ConfigService,
|
||||
private accountService: AccountService,
|
||||
private cipherService: CipherService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -238,6 +241,25 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
notificationId: authRequest.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Store a reference to the current active account during page init
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
// Combine with the activeAccount$ to ensure we only show the dialog for the current account from ngOnInit.
|
||||
// The account switching process updates the cipherService before Vault is destroyed and would cause duplicate emissions
|
||||
combineLatest([this.accountService.activeAccount$, this.cipherService.failedToDecryptCiphers$])
|
||||
.pipe(
|
||||
filter(([account]) => account.id === activeAccount.id),
|
||||
map(([_, ciphers]) => ciphers.filter((c) => !c.isDeleted)),
|
||||
filter((ciphers) => ciphers.length > 0),
|
||||
take(1),
|
||||
takeUntil(this.componentIsDestroyed$),
|
||||
)
|
||||
.subscribe((ciphers) => {
|
||||
DecryptionFailureDialogComponent.open(this.dialogService, {
|
||||
cipherIds: ciphers.map((c) => c.id as CipherId),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
@@ -302,6 +324,12 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
if (cipher.decryptionFailure) {
|
||||
invokeMenu(menu);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cipher.isDeleted) {
|
||||
menu.push({
|
||||
label: this.i18nService.t("edit"),
|
||||
|
||||
@@ -638,33 +638,35 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer" *ngIf="cipher">
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
(click)="edit()"
|
||||
appA11yTitle="{{ 'edit' | i18n }}"
|
||||
*ngIf="!cipher.isDeleted"
|
||||
>
|
||||
<i class="bwi bwi-pencil bwi-fw bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
(click)="restore()"
|
||||
appA11yTitle="{{ 'restore' | i18n }}"
|
||||
*ngIf="cipher.isDeleted"
|
||||
>
|
||||
<i class="bwi bwi-undo bwi-fw bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
*ngIf="!cipher?.organizationId && !cipher.isDeleted"
|
||||
(click)="clone()"
|
||||
appA11yTitle="{{ 'clone' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
<ng-container *ngIf="!cipher.decryptionFailure">
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
(click)="edit()"
|
||||
appA11yTitle="{{ 'edit' | i18n }}"
|
||||
*ngIf="!cipher.isDeleted"
|
||||
>
|
||||
<i class="bwi bwi-pencil bwi-fw bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
(click)="restore()"
|
||||
appA11yTitle="{{ 'restore' | i18n }}"
|
||||
*ngIf="cipher.isDeleted"
|
||||
>
|
||||
<i class="bwi bwi-undo bwi-fw bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
*ngIf="!cipher?.organizationId && !cipher.isDeleted"
|
||||
(click)="clone()"
|
||||
appA11yTitle="{{ 'clone' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-files bwi-fw bwi-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</ng-container>
|
||||
<div class="right" *ngIf="canDeleteCipher$ | async">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -25,6 +25,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
@@ -32,7 +33,7 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
import { DecryptionFailureDialogComponent, PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
const BroadcasterSubscriptionId = "ViewComponent";
|
||||
|
||||
@@ -98,6 +99,7 @@ export class ViewComponent extends BaseViewComponent implements OnInit, OnDestro
|
||||
}
|
||||
ngOnInit() {
|
||||
super.ngOnInit();
|
||||
|
||||
this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => {
|
||||
this.ngZone.run(() => {
|
||||
switch (message.command) {
|
||||
@@ -117,6 +119,13 @@ export class ViewComponent extends BaseViewComponent implements OnInit, OnDestro
|
||||
|
||||
async ngOnChanges() {
|
||||
await super.load();
|
||||
|
||||
if (this.cipher.decryptionFailure) {
|
||||
DecryptionFailureDialogComponent.open(this.dialogService, {
|
||||
cipherIds: [this.cipherId as CipherId],
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
viewHistory() {
|
||||
|
||||
@@ -20,5 +20,7 @@
|
||||
}
|
||||
],
|
||||
"flags": {},
|
||||
"devFlags": {}
|
||||
"devFlags": {
|
||||
"showRiskInsightsDebug": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,13 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
@@ -56,6 +57,8 @@ export class EmergencyAddEditCipherComponent extends BaseAddEditComponent implem
|
||||
configService: ConfigService,
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
cipherAuthorizationService: CipherAuthorizationService,
|
||||
toastService: ToastService,
|
||||
sdkService: SdkService,
|
||||
) {
|
||||
super(
|
||||
cipherService,
|
||||
@@ -78,6 +81,8 @@ export class EmergencyAddEditCipherComponent extends BaseAddEditComponent implem
|
||||
configService,
|
||||
billingAccountProfileStateService,
|
||||
cipherAuthorizationService,
|
||||
toastService,
|
||||
sdkService,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
import { WebLockComponentService } from "./web-lock-component.service";
|
||||
|
||||
@@ -86,7 +87,7 @@ describe("WebLockComponentService", () => {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { BiometricsStatus } from "@bitwarden/key-management";
|
||||
import { LockComponentService, UnlockOptions } from "@bitwarden/key-management/angular";
|
||||
|
||||
export class WebLockComponentService implements LockComponentService {
|
||||
@@ -45,7 +46,7 @@ export class WebLockComponentService implements LockComponentService {
|
||||
},
|
||||
biometrics: {
|
||||
enabled: false,
|
||||
disableReason: null,
|
||||
biometricsStatus: BiometricsStatus.PlatformUnsupported,
|
||||
},
|
||||
};
|
||||
return unlockOpts;
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsService, BiometricsStatus } from "@bitwarden/key-management";
|
||||
|
||||
export class WebBiometricsService extends BiometricsService {
|
||||
async supportsBiometric(): Promise<boolean> {
|
||||
async authenticateWithBiometrics(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async isBiometricUnlockAvailable(): Promise<boolean> {
|
||||
async getBiometricsStatus(): Promise<BiometricsStatus> {
|
||||
return BiometricsStatus.PlatformUnsupported;
|
||||
}
|
||||
|
||||
async unlockWithBiometricsForUser(userId: UserId): Promise<UserKey | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async getBiometricsStatusForUser(userId: UserId): Promise<BiometricsStatus> {
|
||||
return BiometricsStatus.PlatformUnsupported;
|
||||
}
|
||||
|
||||
async getShouldAutopromptNow(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async biometricsNeedsSetup(): Promise<boolean> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async biometricsSupportsAutoSetup(): Promise<boolean> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async biometricsSetup(): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
async setShouldAutopromptNow(value: boolean): Promise<void> {}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
CipherFormGenerationService,
|
||||
CipherFormModule,
|
||||
CipherViewComponent,
|
||||
DecryptionFailureDialogComponent,
|
||||
} from "@bitwarden/vault";
|
||||
|
||||
import { SharedModule } from "../../../shared/shared.module";
|
||||
@@ -114,6 +115,7 @@ export enum VaultItemDialogResult {
|
||||
CipherAttachmentsComponent,
|
||||
AsyncActionsModule,
|
||||
ItemModule,
|
||||
DecryptionFailureDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
{ provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService },
|
||||
@@ -252,6 +254,14 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
this.cipher = await this.getDecryptedCipherView(this.formConfig);
|
||||
|
||||
if (this.cipher) {
|
||||
if (this.cipher.decryptionFailure) {
|
||||
this.dialogService.open(DecryptionFailureDialogComponent, {
|
||||
data: { cipherIds: [this.cipher.id] },
|
||||
});
|
||||
this.dialogRef.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.collections = this.formConfig.collections.filter((c) =>
|
||||
this.cipher.collectionIds?.includes(c.id),
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
type="checkbox"
|
||||
bitCheckbox
|
||||
appStopProp
|
||||
[disabled]="disabled"
|
||||
[disabled]="disabled || cipher.decryptionFailure"
|
||||
[checked]="checked"
|
||||
(change)="$event ? this.checkedToggled.next() : null"
|
||||
[attr.aria-label]="'vaultItemSelect' | i18n"
|
||||
@@ -20,7 +20,7 @@
|
||||
class="tw-overflow-hidden tw-text-ellipsis tw-text-start tw-leading-snug"
|
||||
[disabled]="disabled"
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ itemId: cipher.id, action: extensionRefreshEnabled ? 'view' : null }"
|
||||
[queryParams]="{ itemId: cipher.id, action: clickAction }"
|
||||
queryParamsHandling="merge"
|
||||
[replaceUrl]="extensionRefreshEnabled"
|
||||
title="{{ 'editItemWithName' | i18n: cipher.name }}"
|
||||
@@ -76,6 +76,25 @@
|
||||
</td>
|
||||
<td bitCell [ngClass]="RowHeightClass" class="tw-text-right">
|
||||
<button
|
||||
*ngIf="cipher.decryptionFailure"
|
||||
[disabled]="disabled || !canManageCollection"
|
||||
[bitMenuTriggerFor]="corruptedCipherOptions"
|
||||
size="small"
|
||||
bitIconButton="bwi-ellipsis-v"
|
||||
type="button"
|
||||
appA11yTitle="{{ 'options' | i18n }}"
|
||||
appStopProp
|
||||
></button>
|
||||
<bit-menu #corruptedCipherOptions>
|
||||
<button bitMenuItem *ngIf="canManageCollection" (click)="deleteCipher()" type="button">
|
||||
<span class="tw-text-danger">
|
||||
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
|
||||
{{ (cipher.isDeleted ? "permanentlyDelete" : "delete") | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
</bit-menu>
|
||||
<button
|
||||
*ngIf="!cipher.decryptionFailure"
|
||||
[disabled]="disabled || disableMenu"
|
||||
[bitMenuTriggerFor]="cipherOptions"
|
||||
size="small"
|
||||
|
||||
@@ -78,6 +78,13 @@ export class VaultCipherRowComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
protected get clickAction() {
|
||||
if (this.cipher.decryptionFailure) {
|
||||
return "showFailedToDecrypt";
|
||||
}
|
||||
return this.extensionRefreshEnabled ? "view" : null;
|
||||
}
|
||||
|
||||
protected get showTotpCopyButton() {
|
||||
return (
|
||||
(this.cipher.login?.hasTotp ?? false) &&
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
[(ngModel)]="cipher.type"
|
||||
class="form-control"
|
||||
[disabled]="cipher.isDeleted"
|
||||
(change)="typeChange()"
|
||||
appAutofocus
|
||||
>
|
||||
<option *ngFor="let o of typeOptions" [ngValue]="o.value">{{ o.name }}</option>
|
||||
|
||||
@@ -21,13 +21,14 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { Launchable } from "@bitwarden/common/vault/interfaces/launchable";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
@@ -73,6 +74,8 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
configService: ConfigService,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
cipherAuthorizationService: CipherAuthorizationService,
|
||||
toastService: ToastService,
|
||||
sdkService: SdkService,
|
||||
) {
|
||||
super(
|
||||
cipherService,
|
||||
@@ -93,6 +96,8 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
datePipe,
|
||||
configService,
|
||||
cipherAuthorizationService,
|
||||
toastService,
|
||||
sdkService,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,10 @@
|
||||
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
|
||||
{{ "note" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="addCipher(CipherType.SshKey)">
|
||||
<i class="bwi bwi-key" slot="start" aria-hidden="true"></i>
|
||||
{{ "typeSshKey" | i18n }}
|
||||
</button>
|
||||
<bit-menu-divider />
|
||||
<button type="button" bitMenuItem (click)="addFolder()">
|
||||
<i class="bwi bwi-fw bwi-folder" aria-hidden="true"></i>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
map,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from "rxjs/operators";
|
||||
@@ -75,6 +76,7 @@ import { DialogService, Icons, ToastService } from "@bitwarden/components";
|
||||
import {
|
||||
CipherFormConfig,
|
||||
CollectionAssignmentResult,
|
||||
DecryptionFailureDialogComponent,
|
||||
DefaultCipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
} from "@bitwarden/vault";
|
||||
@@ -144,6 +146,7 @@ const SearchTextDebounceInterval = 200;
|
||||
VaultFilterModule,
|
||||
VaultItemsModule,
|
||||
SharedModule,
|
||||
DecryptionFailureDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
RoutedVaultFilterService,
|
||||
@@ -359,13 +362,16 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
]).pipe(
|
||||
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
|
||||
concatMap(async ([ciphers, filter, searchText]) => {
|
||||
const failedCiphers = await firstValueFrom(this.cipherService.failedToDecryptCiphers$);
|
||||
const filterFunction = createFilterFunction(filter);
|
||||
// Append any failed to decrypt ciphers to the top of the cipher list
|
||||
const allCiphers = [...failedCiphers, ...ciphers];
|
||||
|
||||
if (await this.searchService.isSearchable(searchText)) {
|
||||
return await this.searchService.searchCiphers(searchText, [filterFunction], ciphers);
|
||||
return await this.searchService.searchCiphers(searchText, [filterFunction], allCiphers);
|
||||
}
|
||||
|
||||
return ciphers.filter(filterFunction);
|
||||
return allCiphers.filter(filterFunction);
|
||||
}),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
@@ -436,6 +442,18 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
action = "view";
|
||||
}
|
||||
|
||||
if (action == "showFailedToDecrypt") {
|
||||
DecryptionFailureDialogComponent.open(this.dialogService, {
|
||||
cipherIds: [cipherId as CipherId],
|
||||
});
|
||||
await this.router.navigate([], {
|
||||
queryParams: { itemId: null, cipherId: null, action: null },
|
||||
queryParamsHandling: "merge",
|
||||
replaceUrl: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "view") {
|
||||
await this.viewCipherById(cipherId);
|
||||
} else {
|
||||
@@ -458,6 +476,20 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
firstSetup$
|
||||
.pipe(
|
||||
switchMap(() => this.cipherService.failedToDecryptCiphers$),
|
||||
map((ciphers) => ciphers.filter((c) => !c.isDeleted)),
|
||||
filter((ciphers) => ciphers.length > 0),
|
||||
take(1),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe((ciphers) => {
|
||||
DecryptionFailureDialogComponent.open(this.dialogService, {
|
||||
cipherIds: ciphers.map((c) => c.id as CipherId),
|
||||
});
|
||||
});
|
||||
|
||||
this.unpaidSubscriptionDialog$.pipe(takeUntil(this.destroy$)).subscribe();
|
||||
|
||||
firstSetup$
|
||||
@@ -747,16 +779,26 @@ export class VaultComponent implements OnInit, OnDestroy {
|
||||
null,
|
||||
cipherType,
|
||||
);
|
||||
const collectionId =
|
||||
this.activeFilter.collectionId !== "AllCollections" && this.activeFilter.collectionId != null
|
||||
? this.activeFilter.collectionId
|
||||
: null;
|
||||
let organizationId =
|
||||
this.activeFilter.organizationId !== "MyVault" && this.activeFilter.organizationId != null
|
||||
? this.activeFilter.organizationId
|
||||
: null;
|
||||
// Attempt to get the organization ID from the collection if present
|
||||
if (collectionId) {
|
||||
const organizationIdFromCollection = (
|
||||
await firstValueFrom(this.vaultFilterService.filteredCollections$)
|
||||
).find((c) => c.id === this.activeFilter.collectionId)?.organizationId;
|
||||
if (organizationIdFromCollection) {
|
||||
organizationId = organizationIdFromCollection;
|
||||
}
|
||||
}
|
||||
cipherFormConfig.initialValues = {
|
||||
organizationId:
|
||||
this.activeFilter.organizationId !== "MyVault" && this.activeFilter.organizationId != null
|
||||
? (this.activeFilter.organizationId as OrganizationId)
|
||||
: null,
|
||||
collectionIds:
|
||||
this.activeFilter.collectionId !== "AllCollections" &&
|
||||
this.activeFilter.collectionId != null
|
||||
? [this.activeFilter.collectionId as CollectionId]
|
||||
: [],
|
||||
organizationId: organizationId as OrganizationId,
|
||||
collectionIds: [collectionId as CollectionId],
|
||||
folderId: this.activeFilter.folderId,
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user