1
0
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:
cd-bitwarden
2025-01-08 16:59:05 -05:00
committed by GitHub
139 changed files with 2937 additions and 2049 deletions

18
.github/renovate.json vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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> {}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +0,0 @@
export * from "./desktop.biometrics.service";
export * from "./biometrics.service";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

@@ -1,5 +1,6 @@
export type LegacyMessage = {
command: string;
messageId: number;
userId?: string;
timestamp?: number;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,5 +20,7 @@
}
],
"flags": {},
"devFlags": {}
"devFlags": {
"showRiskInsightsDebug": false
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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