mirror of
https://github.com/bitwarden/browser
synced 2025-12-13 23:03:32 +00:00
Merge branch 'main' into ac/pm-10324/add-bulk-delete-option-for-members
This commit is contained in:
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -122,6 +122,9 @@ apps/cli/src/locales/en/messages.json
|
||||
apps/desktop/src/locales/en/messages.json
|
||||
apps/web/src/locales/en/messages.json
|
||||
|
||||
## Ssh agent temporary co-codeowner
|
||||
apps/desktop/desktop_native/core/src/ssh_agent @bitwarden/team-platform-dev @bitwarden/wg-ssh-keys
|
||||
|
||||
## BRE team owns these workflows ##
|
||||
.github/workflows/brew-bump-desktop.yml @bitwarden/dept-bre
|
||||
.github/workflows/deploy-web.yml @bitwarden/dept-bre
|
||||
|
||||
50
.github/renovate.json
vendored
50
.github/renovate.json
vendored
@@ -41,16 +41,12 @@
|
||||
},
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"@ngtools/webpack",
|
||||
"base64-loader",
|
||||
"buffer",
|
||||
"bufferutil",
|
||||
"copy-webpack-plugin",
|
||||
"core-js",
|
||||
"css-loader",
|
||||
"html-loader",
|
||||
"html-webpack-injector",
|
||||
"html-webpack-plugin",
|
||||
"mini-css-extract-plugin",
|
||||
"ngx-infinite-scroll",
|
||||
"postcss",
|
||||
@@ -60,20 +56,15 @@
|
||||
"sass-loader",
|
||||
"style-loader",
|
||||
"ts-loader",
|
||||
"tsconfig-paths-webpack-plugin",
|
||||
"url",
|
||||
"util",
|
||||
"webpack",
|
||||
"webpack-cli",
|
||||
"webpack-dev-server",
|
||||
"webpack-node-externals"
|
||||
"util"
|
||||
],
|
||||
"description": "Admin Console owned dependencies",
|
||||
"commitMessagePrefix": "[deps] AC:",
|
||||
"reviewers": ["team:team-admin-console-dev"]
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["@types/node-ipc", "node-ipc", "qrious"],
|
||||
"matchPackageNames": ["qrious"],
|
||||
"description": "Auth owned dependencies",
|
||||
"commitMessagePrefix": "[deps] Auth:",
|
||||
"reviewers": ["team:team-auth-dev"]
|
||||
@@ -110,27 +101,43 @@
|
||||
},
|
||||
{
|
||||
"matchPackageNames": [
|
||||
"@babel/core",
|
||||
"@babel/preset-env",
|
||||
"@electron/notarize",
|
||||
"@electron/rebuild",
|
||||
"@types/argon2-browser",
|
||||
"@ngtools/webpack",
|
||||
"@types/chrome",
|
||||
"@types/firefox-webext-browser",
|
||||
"@types/glob",
|
||||
"@types/jquery",
|
||||
"@types/lowdb",
|
||||
"@types/node",
|
||||
"@types/node-forge",
|
||||
"argon2",
|
||||
"argon2-browser",
|
||||
"big-integer",
|
||||
"@types/node-ipc",
|
||||
"@yao-pkg",
|
||||
"babel-loader",
|
||||
"browserslist",
|
||||
"copy-webpack-plugin",
|
||||
"electron",
|
||||
"electron-builder",
|
||||
"electron-log",
|
||||
"electron-reload",
|
||||
"electron-store",
|
||||
"electron-updater",
|
||||
"electron",
|
||||
"html-webpack-injector",
|
||||
"html-webpack-plugin",
|
||||
"lowdb",
|
||||
"node-forge",
|
||||
"node-ipc",
|
||||
"pkg",
|
||||
"rxjs",
|
||||
"tsconfig-paths-webpack-plugin",
|
||||
"type-fest",
|
||||
"typescript"
|
||||
"typescript",
|
||||
"webpack",
|
||||
"webpack-cli",
|
||||
"webpack-dev-server",
|
||||
"webpack-node-externals"
|
||||
],
|
||||
"description": "Platform owned dependencies",
|
||||
"commitMessagePrefix": "[deps] Platform:",
|
||||
@@ -231,7 +238,6 @@
|
||||
"@types/koa__router",
|
||||
"@types/koa-bodyparser",
|
||||
"@types/koa-json",
|
||||
"@types/lowdb",
|
||||
"@types/lunr",
|
||||
"@types/node-fetch",
|
||||
"@types/proper-lockfile",
|
||||
@@ -244,18 +250,22 @@
|
||||
"koa",
|
||||
"koa-bodyparser",
|
||||
"koa-json",
|
||||
"lowdb",
|
||||
"lunr",
|
||||
"multer",
|
||||
"node-fetch",
|
||||
"open",
|
||||
"pkg",
|
||||
"proper-lockfile",
|
||||
"qrcode-parser"
|
||||
],
|
||||
"description": "Vault owned dependencies",
|
||||
"commitMessagePrefix": "[deps] Vault:",
|
||||
"reviewers": ["team:team-vault-dev"]
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["@types/argon2-browser", "argon2", "argon2-browser", "big-integer"],
|
||||
"description": "Key Management owned dependencies",
|
||||
"commitMessagePrefix": "[deps] KM:",
|
||||
"reviewers": ["team:team-key-management-dev"]
|
||||
}
|
||||
],
|
||||
"ignoreDeps": ["@types/koa-bodyparser", "bootstrap", "node-ipc", "node", "npm"]
|
||||
|
||||
33
.github/workflows/build-browser.yml
vendored
33
.github/workflows/build-browser.yml
vendored
@@ -1,7 +1,8 @@
|
||||
name: Build Browser
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
branches-ignore:
|
||||
- 'l10n_master'
|
||||
- 'cf-pages'
|
||||
@@ -33,6 +34,10 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
setup:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -41,8 +46,10 @@ jobs:
|
||||
adj_build_number: ${{ steps.gen_vars.outputs.adj_build_number }}
|
||||
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Get Package Version
|
||||
id: gen_vars
|
||||
@@ -71,8 +78,10 @@ jobs:
|
||||
run:
|
||||
working-directory: apps/browser
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Testing locales - extName length
|
||||
run: |
|
||||
@@ -109,8 +118,10 @@ jobs:
|
||||
_BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }}
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -225,12 +236,15 @@ jobs:
|
||||
needs:
|
||||
- setup
|
||||
- locales-test
|
||||
- check-run
|
||||
env:
|
||||
_BUILD_NUMBER: ${{ needs.setup.outputs.adj_build_number }}
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -342,8 +356,10 @@ jobs:
|
||||
- build
|
||||
- build-safari
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Login to Azure
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
@@ -381,7 +397,10 @@ jobs:
|
||||
- crowdin-push
|
||||
steps:
|
||||
- name: Check if any job failed
|
||||
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure')
|
||||
if: |
|
||||
github.event_name != 'pull_request_target'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
&& contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
- name: Login to Azure - Prod Subscription
|
||||
|
||||
30
.github/workflows/build-cli.yml
vendored
30
.github/workflows/build-cli.yml
vendored
@@ -1,7 +1,8 @@
|
||||
name: Build CLI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
branches-ignore:
|
||||
- 'l10n_master'
|
||||
- 'cf-pages'
|
||||
@@ -34,6 +35,10 @@ defaults:
|
||||
working-directory: apps/cli
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
setup:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -41,8 +46,10 @@ jobs:
|
||||
package_version: ${{ steps.retrieve-package-version.outputs.package_version }}
|
||||
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Get Package Version
|
||||
id: retrieve-package-version
|
||||
@@ -58,7 +65,6 @@ jobs:
|
||||
NODE_VERSION=${NODE_NVMRC/v/''}
|
||||
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
cli:
|
||||
name: "${{ matrix.os.base }} - ${{ matrix.license_type.readable }}"
|
||||
strategy:
|
||||
@@ -82,8 +88,10 @@ jobs:
|
||||
_WIN_PKG_FETCH_VERSION: 20.11.1
|
||||
_WIN_PKG_VERSION: 3.5
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Setup Unix Vars
|
||||
run: |
|
||||
@@ -160,8 +168,10 @@ jobs:
|
||||
_WIN_PKG_FETCH_VERSION: 20.11.1
|
||||
_WIN_PKG_VERSION: 3.5
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Setup Windows builder
|
||||
run: |
|
||||
@@ -310,8 +320,10 @@ jobs:
|
||||
env:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@@ -386,10 +398,14 @@ jobs:
|
||||
- cli
|
||||
- cli-windows
|
||||
- snap
|
||||
- check-run
|
||||
steps:
|
||||
- name: Check if any job failed
|
||||
working-directory: ${{ github.workspace }}
|
||||
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure')
|
||||
if: |
|
||||
github.event_name != 'pull_request_target'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
&& contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
- name: Login to Azure - Prod Subscription
|
||||
|
||||
56
.github/workflows/build-desktop.yml
vendored
56
.github/workflows/build-desktop.yml
vendored
@@ -1,7 +1,8 @@
|
||||
name: Build Desktop
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
branches-ignore:
|
||||
- 'l10n_master'
|
||||
- 'cf-pages'
|
||||
@@ -32,12 +33,18 @@ defaults:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
electron-verify:
|
||||
name: Verify Electron Version
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Verify
|
||||
run: |
|
||||
@@ -65,8 +72,10 @@ jobs:
|
||||
run:
|
||||
working-directory: apps/desktop
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Get Package Version
|
||||
id: retrieve-version
|
||||
@@ -138,8 +147,10 @@ jobs:
|
||||
run:
|
||||
working-directory: apps/desktop
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -238,7 +249,9 @@ jobs:
|
||||
windows:
|
||||
name: Windows Build
|
||||
runs-on: windows-2022
|
||||
needs: setup
|
||||
needs:
|
||||
- setup
|
||||
- check-run
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
@@ -248,8 +261,10 @@ jobs:
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
NODE_OPTIONS: --max_old_space_size=4096
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -447,7 +462,9 @@ jobs:
|
||||
macos-build:
|
||||
name: MacOS Build
|
||||
runs-on: macos-13
|
||||
needs: setup
|
||||
needs:
|
||||
- setup
|
||||
- check-run
|
||||
env:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
@@ -456,8 +473,10 @@ jobs:
|
||||
run:
|
||||
working-directory: apps/desktop
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -622,8 +641,10 @@ jobs:
|
||||
run:
|
||||
working-directory: apps/desktop
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -841,8 +862,10 @@ jobs:
|
||||
run:
|
||||
working-directory: apps/desktop
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -1088,8 +1111,10 @@ jobs:
|
||||
run:
|
||||
working-directory: apps/desktop
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -1279,8 +1304,10 @@ jobs:
|
||||
- macos-package-mas
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Login to Azure
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
@@ -1323,7 +1350,10 @@ jobs:
|
||||
- crowdin-push
|
||||
steps:
|
||||
- name: Check if any job failed
|
||||
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure')
|
||||
if: |
|
||||
github.event_name != 'pull_request_target'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
&& contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
- name: Login to Azure - Prod Subscription
|
||||
|
||||
39
.github/workflows/build-web.yml
vendored
39
.github/workflows/build-web.yml
vendored
@@ -1,7 +1,8 @@
|
||||
name: Build Web
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
branches-ignore:
|
||||
- 'l10n_master'
|
||||
- 'cf-pages'
|
||||
@@ -36,6 +37,10 @@ env:
|
||||
_AZ_REGISTRY: bitwardenprod.azurecr.io
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
|
||||
setup:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
@@ -43,8 +48,10 @@ jobs:
|
||||
version: ${{ steps.version.outputs.value }}
|
||||
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Get GitHub sha as version
|
||||
id: version
|
||||
@@ -89,8 +96,10 @@ jobs:
|
||||
git_metadata: true
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
@@ -142,6 +151,7 @@ jobs:
|
||||
needs:
|
||||
- setup
|
||||
- build-artifacts
|
||||
- check-run
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -155,8 +165,10 @@ jobs:
|
||||
env:
|
||||
_VERSION: ${{ needs.setup.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Check Branch to Publish
|
||||
env:
|
||||
@@ -250,11 +262,15 @@ jobs:
|
||||
crowdin-push:
|
||||
name: Crowdin Push
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: build-artifacts
|
||||
needs:
|
||||
- build-artifacts
|
||||
- check-run
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Login to Azure
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
@@ -282,9 +298,11 @@ jobs:
|
||||
|
||||
trigger-web-vault-deploy:
|
||||
name: Trigger web vault deploy
|
||||
if: github.ref == 'refs/heads/main'
|
||||
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-22.04
|
||||
needs: build-artifacts
|
||||
needs:
|
||||
- build-artifacts
|
||||
- check-run
|
||||
steps:
|
||||
- name: Login to Azure - CI Subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
@@ -326,7 +344,10 @@ jobs:
|
||||
- trigger-web-vault-deploy
|
||||
steps:
|
||||
- name: Check if any job failed
|
||||
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc') && contains(needs.*.result, 'failure')
|
||||
if: |
|
||||
github.event_name != 'pull_request_target'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
&& contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
- name: Login to Azure - Prod Subscription
|
||||
|
||||
@@ -2878,7 +2878,7 @@
|
||||
"message": "E-Mail generieren"
|
||||
},
|
||||
"generatorBoundariesHint": {
|
||||
"message": "Value must be between $MIN$ and $MAX$",
|
||||
"message": "Wert muss zwischen $MIN$ und $MAX$ liegen",
|
||||
"description": "Explains spin box minimum and maximum values to the user",
|
||||
"placeholders": {
|
||||
"min": {
|
||||
|
||||
@@ -152,6 +152,15 @@
|
||||
"copyLicenseNumber": {
|
||||
"message": "Copy license number"
|
||||
},
|
||||
"copyPrivateKey": {
|
||||
"message": "Copy private key"
|
||||
},
|
||||
"copyPublicKey": {
|
||||
"message": "Copy public key"
|
||||
},
|
||||
"copyFingerprint": {
|
||||
"message": "Copy fingerprint"
|
||||
},
|
||||
"copyCustomField": {
|
||||
"message": "Copy $FIELD$",
|
||||
"placeholders": {
|
||||
@@ -1764,6 +1773,9 @@
|
||||
"typeIdentity": {
|
||||
"message": "Identity"
|
||||
},
|
||||
"typeSshKey": {
|
||||
"message": "SSH key"
|
||||
},
|
||||
"newItemHeader": {
|
||||
"message": "New $TYPE$",
|
||||
"placeholders": {
|
||||
@@ -4593,6 +4605,30 @@
|
||||
"enterprisePolicyRequirementsApplied": {
|
||||
"message": "Enterprise policy requirements have been applied to this setting"
|
||||
},
|
||||
"sshPrivateKey": {
|
||||
"message": "Private key"
|
||||
},
|
||||
"sshPublicKey": {
|
||||
"message": "Public key"
|
||||
},
|
||||
"sshFingerprint": {
|
||||
"message": "Fingerprint"
|
||||
},
|
||||
"sshKeyAlgorithm": {
|
||||
"message": "Key type"
|
||||
},
|
||||
"sshKeyAlgorithmED25519": {
|
||||
"message": "ED25519"
|
||||
},
|
||||
"sshKeyAlgorithmRSA2048": {
|
||||
"message": "RSA 2048-Bit"
|
||||
},
|
||||
"sshKeyAlgorithmRSA3072": {
|
||||
"message": "RSA 3072-Bit"
|
||||
},
|
||||
"sshKeyAlgorithmRSA4096": {
|
||||
"message": "RSA 4096-Bit"
|
||||
},
|
||||
"retry": {
|
||||
"message": "Retry"
|
||||
},
|
||||
|
||||
@@ -20,16 +20,16 @@
|
||||
"message": "Crear cuenta"
|
||||
},
|
||||
"newToBitwarden": {
|
||||
"message": "New to Bitwarden?"
|
||||
"message": "¿Nuevo en Bitwarden?"
|
||||
},
|
||||
"logInWithPasskey": {
|
||||
"message": "Log in with passkey"
|
||||
"message": "Iniciar sesión con clave de acceso"
|
||||
},
|
||||
"useSingleSignOn": {
|
||||
"message": "Use single sign-on"
|
||||
"message": "Usar inicio de sesión único"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"message": "Welcome back"
|
||||
"message": "Bienvenido de nuevo"
|
||||
},
|
||||
"setAStrongPassword": {
|
||||
"message": "Establece una contraseña fuerte"
|
||||
@@ -84,7 +84,7 @@
|
||||
"message": "Incorporarse a la organización"
|
||||
},
|
||||
"joinOrganizationName": {
|
||||
"message": "Join $ORGANIZATIONNAME$",
|
||||
"message": "Unirse a $ORGANIZATIONNAME$",
|
||||
"placeholders": {
|
||||
"organizationName": {
|
||||
"content": "$1",
|
||||
@@ -120,7 +120,7 @@
|
||||
"message": "Copiar contraseña"
|
||||
},
|
||||
"copyPassphrase": {
|
||||
"message": "Copy passphrase"
|
||||
"message": "Copiar frase de contraseña"
|
||||
},
|
||||
"copyNote": {
|
||||
"message": "Copiar nota"
|
||||
@@ -153,7 +153,7 @@
|
||||
"message": "Copiar número de licencia"
|
||||
},
|
||||
"copyCustomField": {
|
||||
"message": "Copy $FIELD$",
|
||||
"message": "Copiar $FIELD$",
|
||||
"placeholders": {
|
||||
"field": {
|
||||
"content": "$1",
|
||||
@@ -162,13 +162,13 @@
|
||||
}
|
||||
},
|
||||
"copyWebsite": {
|
||||
"message": "Copy website"
|
||||
"message": "Copiar sitio web"
|
||||
},
|
||||
"copyNotes": {
|
||||
"message": "Copy notes"
|
||||
"message": "Copiar notas"
|
||||
},
|
||||
"fill": {
|
||||
"message": "Fill",
|
||||
"message": "Rellenar",
|
||||
"description": "This string is used on the vault page to indicate autofilling. Horizontal space is limited in the interface here so try and keep translations as concise as possible."
|
||||
},
|
||||
"autoFill": {
|
||||
@@ -223,13 +223,13 @@
|
||||
"message": "Añadir elemento"
|
||||
},
|
||||
"accountEmail": {
|
||||
"message": "Account email"
|
||||
"message": "Correo electrónico de la cuenta"
|
||||
},
|
||||
"requestHint": {
|
||||
"message": "Request hint"
|
||||
"message": "Solicitar pista"
|
||||
},
|
||||
"requestPasswordHint": {
|
||||
"message": "Request password hint"
|
||||
"message": "Solicitar pista de la contraseña"
|
||||
},
|
||||
"enterYourAccountEmailAddressAndYourPasswordHintWillBeSentToYou": {
|
||||
"message": "Enter your account email address and your password hint will be sent to you"
|
||||
@@ -427,7 +427,7 @@
|
||||
"message": "Generar contraseña"
|
||||
},
|
||||
"generatePassphrase": {
|
||||
"message": "Generate passphrase"
|
||||
"message": "Generar frase de contraseña"
|
||||
},
|
||||
"regeneratePassword": {
|
||||
"message": "Regenerar contraseña"
|
||||
@@ -567,7 +567,7 @@
|
||||
"message": "Notas"
|
||||
},
|
||||
"privateNote": {
|
||||
"message": "Private note"
|
||||
"message": "Nota privada"
|
||||
},
|
||||
"note": {
|
||||
"message": "Nota"
|
||||
@@ -624,7 +624,7 @@
|
||||
"message": "Tiempo de sesión agotado"
|
||||
},
|
||||
"vaultTimeoutHeader": {
|
||||
"message": "Vault timeout"
|
||||
"message": "Tiempo de espera de la caja fuerte"
|
||||
},
|
||||
"otherOptions": {
|
||||
"message": "Otras opciones"
|
||||
@@ -645,13 +645,13 @@
|
||||
"message": "Tu caja fuerte está bloqueada. Verifica tu identidad para continuar."
|
||||
},
|
||||
"yourVaultIsLockedV2": {
|
||||
"message": "Your vault is locked"
|
||||
"message": "Tu caja fuerte está bloqueada"
|
||||
},
|
||||
"yourAccountIsLocked": {
|
||||
"message": "Your account is locked"
|
||||
"message": "Tu cuenta está bloqueada"
|
||||
},
|
||||
"or": {
|
||||
"message": "or"
|
||||
"message": "o"
|
||||
},
|
||||
"unlock": {
|
||||
"message": "Desbloquear"
|
||||
@@ -676,7 +676,7 @@
|
||||
"message": "Tiempo de espera de la caja fuerte"
|
||||
},
|
||||
"vaultTimeout1": {
|
||||
"message": "Timeout"
|
||||
"message": "Tiempo de espera"
|
||||
},
|
||||
"lockNow": {
|
||||
"message": "Bloquear"
|
||||
@@ -4708,11 +4708,11 @@
|
||||
"description": "Represents the - key in screen reader content as a readable word"
|
||||
},
|
||||
"plusCharacterDescriptor": {
|
||||
"message": "Plus",
|
||||
"message": "Más",
|
||||
"description": "Represents the + key in screen reader content as a readable word"
|
||||
},
|
||||
"equalsCharacterDescriptor": {
|
||||
"message": "Equals",
|
||||
"message": "Igual",
|
||||
"description": "Represents the = key in screen reader content as a readable word"
|
||||
},
|
||||
"braceLeftCharacterDescriptor": {
|
||||
@@ -4736,15 +4736,15 @@
|
||||
"description": "Represents the | key in screen reader content as a readable word"
|
||||
},
|
||||
"backSlashCharacterDescriptor": {
|
||||
"message": "Back slash",
|
||||
"message": "Contrabarra",
|
||||
"description": "Represents the back slash key in screen reader content as a readable word"
|
||||
},
|
||||
"colonCharacterDescriptor": {
|
||||
"message": "Colon",
|
||||
"message": "Dos puntos",
|
||||
"description": "Represents the : key in screen reader content as a readable word"
|
||||
},
|
||||
"semicolonCharacterDescriptor": {
|
||||
"message": "Semicolon",
|
||||
"message": "Punto y coma",
|
||||
"description": "Represents the ; key in screen reader content as a readable word"
|
||||
},
|
||||
"doubleQuoteCharacterDescriptor": {
|
||||
|
||||
@@ -1424,7 +1424,7 @@
|
||||
"message": "Palvelimen URL"
|
||||
},
|
||||
"selfHostBaseUrl": {
|
||||
"message": "Self-host server URL",
|
||||
"message": "Itse ylläpidetyn palvelimen URL-osoite",
|
||||
"description": "Label for field requesting a self-hosted integration service URL"
|
||||
},
|
||||
"apiUrl": {
|
||||
@@ -1795,13 +1795,13 @@
|
||||
"message": "Salasanahistoria"
|
||||
},
|
||||
"generatorHistory": {
|
||||
"message": "Generator history"
|
||||
"message": "Generaattorihistoria"
|
||||
},
|
||||
"clearGeneratorHistoryTitle": {
|
||||
"message": "Clear generator history"
|
||||
"message": "Tyhjennä generaattorihistoria"
|
||||
},
|
||||
"cleargGeneratorHistoryDescription": {
|
||||
"message": "If you continue, all entries will be permanently deleted from generator's history. Are you sure you want to continue?"
|
||||
"message": "Jos jatkat, kaikki generaattorihistorian kohteet poistetaan. Haluatko varmasti jatkaa?"
|
||||
},
|
||||
"back": {
|
||||
"message": "Takaisin"
|
||||
@@ -1920,7 +1920,7 @@
|
||||
"message": "Tyhjennä historia"
|
||||
},
|
||||
"nothingToShow": {
|
||||
"message": "Nothing to show"
|
||||
"message": "Mitään näytettävää ei ole"
|
||||
},
|
||||
"nothingGeneratedRecently": {
|
||||
"message": "Et ole luonut mitään hiljattain"
|
||||
@@ -2710,7 +2710,7 @@
|
||||
"description": "Used as a card title description on the set password page to explain why the user is there"
|
||||
},
|
||||
"cardMetrics": {
|
||||
"message": "out of $TOTAL$",
|
||||
"message": "/$TOTAL$",
|
||||
"placeholders": {
|
||||
"total": {
|
||||
"content": "$1",
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
"message": "Kopiera personnummer"
|
||||
},
|
||||
"copyPassportNumber": {
|
||||
"message": "Copy passport number"
|
||||
"message": "Kopiera passnummer"
|
||||
},
|
||||
"copyLicenseNumber": {
|
||||
"message": "Copy license number"
|
||||
@@ -4624,7 +4624,7 @@
|
||||
"message": "Items that have been in trash more than 30 days will automatically be deleted"
|
||||
},
|
||||
"restore": {
|
||||
"message": "Restore"
|
||||
"message": "Återställ"
|
||||
},
|
||||
"deleteForever": {
|
||||
"message": "Delete forever"
|
||||
@@ -4744,7 +4744,7 @@
|
||||
"description": "Represents the : key in screen reader content as a readable word"
|
||||
},
|
||||
"semicolonCharacterDescriptor": {
|
||||
"message": "Semicolon",
|
||||
"message": "Semikolon",
|
||||
"description": "Represents the ; key in screen reader content as a readable word"
|
||||
},
|
||||
"doubleQuoteCharacterDescriptor": {
|
||||
@@ -4756,11 +4756,11 @@
|
||||
"description": "Represents the ' key in screen reader content as a readable word"
|
||||
},
|
||||
"lessThanCharacterDescriptor": {
|
||||
"message": "Less than",
|
||||
"message": "Mindre än",
|
||||
"description": "Represents the < key in screen reader content as a readable word"
|
||||
},
|
||||
"greaterThanCharacterDescriptor": {
|
||||
"message": "Greater than",
|
||||
"message": "Större än",
|
||||
"description": "Represents the > key in screen reader content as a readable word"
|
||||
},
|
||||
"commaCharacterDescriptor": {
|
||||
@@ -4772,7 +4772,7 @@
|
||||
"description": "Represents the . key in screen reader content as a readable word"
|
||||
},
|
||||
"questionCharacterDescriptor": {
|
||||
"message": "Question mark",
|
||||
"message": "Frågetecken",
|
||||
"description": "Represents the ? key in screen reader content as a readable word"
|
||||
},
|
||||
"forwardSlashCharacterDescriptor": {
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
"message": "複製備註"
|
||||
},
|
||||
"fill": {
|
||||
"message": "Fill",
|
||||
"message": "填入",
|
||||
"description": "This string is used on the vault page to indicate autofilling. Horizontal space is limited in the interface here so try and keep translations as concise as possible."
|
||||
},
|
||||
"autoFill": {
|
||||
@@ -458,7 +458,7 @@
|
||||
"description": "deprecated. Use specialCharactersLabel instead."
|
||||
},
|
||||
"include": {
|
||||
"message": "Include",
|
||||
"message": "包含",
|
||||
"description": "Card header for password generator include block"
|
||||
},
|
||||
"uppercaseDescription": {
|
||||
@@ -730,10 +730,10 @@
|
||||
"message": "安全"
|
||||
},
|
||||
"confirmMasterPassword": {
|
||||
"message": "Confirm master password"
|
||||
"message": "確認主密碼"
|
||||
},
|
||||
"masterPassword": {
|
||||
"message": "Master password"
|
||||
"message": "主密碼"
|
||||
},
|
||||
"masterPassImportant": {
|
||||
"message": "Your master password cannot be recovered if you forget it!"
|
||||
@@ -1092,10 +1092,10 @@
|
||||
"message": "This file export will be password protected and require the file password to decrypt."
|
||||
},
|
||||
"filePassword": {
|
||||
"message": "File password"
|
||||
"message": "檔案密碼"
|
||||
},
|
||||
"exportPasswordDescription": {
|
||||
"message": "This password will be used to export and import this file"
|
||||
"message": "此密碼將用於匯出和匯入此檔案"
|
||||
},
|
||||
"accountRestrictedOptionDescription": {
|
||||
"message": "Use your account encryption key, derived from your account's username and Master Password, to encrypt the export and restrict import to only the current Bitwarden account."
|
||||
@@ -3542,7 +3542,7 @@
|
||||
"description": "Screen reader text (aria-label) for new item button in overlay"
|
||||
},
|
||||
"newLogin": {
|
||||
"message": "New login",
|
||||
"message": "新增登入資訊",
|
||||
"description": "Button text to display within inline menu when there are no matching items on a login field"
|
||||
},
|
||||
"addNewLoginItemAria": {
|
||||
|
||||
@@ -219,7 +219,11 @@ export class AutofillComponent implements OnInit {
|
||||
: AutofillOverlayVisibility.Off;
|
||||
|
||||
await this.autofillSettingsService.setInlineMenuVisibility(newInlineMenuVisibilityValue);
|
||||
await this.requestPrivacyPermission();
|
||||
|
||||
// No need to initiate browser permission request if a feature is being turned off
|
||||
if (newInlineMenuVisibilityValue !== AutofillOverlayVisibility.Off) {
|
||||
await this.requestPrivacyPermission();
|
||||
}
|
||||
}
|
||||
|
||||
async updateAutofillOnPageLoad() {
|
||||
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
SendFormConfig,
|
||||
SendFormConfigService,
|
||||
SendFormMode,
|
||||
SendFormModule,
|
||||
} from "@bitwarden/send-ui";
|
||||
|
||||
import { SendFormModule } from "../../../../../../../libs/tools/send/send-ui/src/send-form/send-form.module";
|
||||
import { PopupFooterComponent } from "../../../../platform/popup/layout/popup-footer.component";
|
||||
import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-header.component";
|
||||
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
|
||||
|
||||
@@ -331,6 +331,8 @@ export class AddEditV2Component implements OnInit {
|
||||
return this.i18nService.t(partOne, this.i18nService.t("typeIdentity").toLocaleLowerCase());
|
||||
case CipherType.SecureNote:
|
||||
return this.i18nService.t(partOne, this.i18nService.t("note").toLocaleLowerCase());
|
||||
case CipherType.SshKey:
|
||||
return this.i18nService.t(partOne, this.i18nService.t("typeSshKey").toLocaleLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,3 +88,27 @@
|
||||
[cipher]="cipher"
|
||||
></button>
|
||||
</bit-item-action>
|
||||
|
||||
<bit-item-action *ngIf="cipher.type === CipherType.SshKey">
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-clone"
|
||||
size="small"
|
||||
[appA11yTitle]="
|
||||
hasSshKeyValues ? ('copyInfoTitle' | i18n: cipher.name) : ('noValuesToCopy' | i18n)
|
||||
"
|
||||
[disabled]="!hasSshKeyValues"
|
||||
[bitMenuTriggerFor]="sshKeyOptions"
|
||||
></button>
|
||||
<bit-menu #sshKeyOptions>
|
||||
<button type="button" bitMenuItem appCopyField="privateKey" [cipher]="cipher">
|
||||
{{ "copyPrivateKey" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem appCopyField="publicKey" [cipher]="cipher">
|
||||
{{ "copyPublicKey" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem appCopyField="keyFingerprint" [cipher]="cipher">
|
||||
{{ "copyFingerprint" | i18n }}
|
||||
</button>
|
||||
</bit-menu>
|
||||
</bit-item-action>
|
||||
|
||||
@@ -48,5 +48,13 @@ export class ItemCopyActionsComponent {
|
||||
return !!this.cipher.notes;
|
||||
}
|
||||
|
||||
get hasSshKeyValues() {
|
||||
return (
|
||||
!!this.cipher.sshKey.privateKey ||
|
||||
!!this.cipher.sshKey.publicKey ||
|
||||
!!this.cipher.sshKey.keyFingerprint
|
||||
);
|
||||
}
|
||||
|
||||
constructor() {}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,8 @@ export class ViewV2Component {
|
||||
);
|
||||
case CipherType.SecureNote:
|
||||
return this.i18nService.t("viewItemHeader", this.i18nService.t("note").toLowerCase());
|
||||
case CipherType.SshKey:
|
||||
return this.i18nService.t("viewItemHeader", this.i18nService.t("typeSshkey").toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -529,6 +529,26 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SshKey -->
|
||||
<div *ngIf="cipher.sshKey">
|
||||
<div class="box-content-row" *ngIf="cipher.sshKey.privateKey" style="overflow: hidden">
|
||||
<span class="row-label"> {{ "sshPrivateKey" | i18n }}</span>
|
||||
{{ cipher.sshKey.privateKey }}
|
||||
</div>
|
||||
<div class="box-content-row" *ngIf="cipher.sshKey.publicKey" style="overflow: hidden">
|
||||
<span class="row-label"> {{ "sshPublicKey" | i18n }}</span>
|
||||
{{ cipher.sshKey.publicKey }}
|
||||
</div>
|
||||
<div
|
||||
class="box-content-row"
|
||||
*ngIf="cipher.sshKey.keyFingerprint"
|
||||
style="overflow: hidden"
|
||||
>
|
||||
<span class="row-label"> {{ "sshKeyFingerprint" | i18n }}</span>
|
||||
{{ cipher.sshKey.keyFingerprint }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" *ngIf="cipher.type === cipherType.Login">
|
||||
|
||||
@@ -114,6 +114,19 @@
|
||||
<span class="row-sub-label">{{ typeCounts.get(cipherType.SecureNote) || 0 }}</span>
|
||||
<span><i class="bwi bwi-angle-right bwi-lg row-sub-icon"></i></span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="box-content-row"
|
||||
appStopClick
|
||||
(click)="selectType(cipherType.SshKey)"
|
||||
>
|
||||
<div class="row-main">
|
||||
<div class="icon"><i class="bwi bwi-fw bwi-lg bwi-key"></i></div>
|
||||
<span class="text">{{ "typeSshKey" | i18n }}</span>
|
||||
</div>
|
||||
<span class="row-sub-label">{{ typeCounts.get(cipherType.SshKey) || 0 }}</span>
|
||||
<span><i class="bwi bwi-angle-right bwi-lg row-sub-icon"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box list" *ngIf="nestedFolders?.length">
|
||||
|
||||
@@ -106,6 +106,9 @@ export class VaultItemsComponent extends BaseVaultItemsComponent implements OnIn
|
||||
case CipherType.SecureNote:
|
||||
this.groupingTitle = this.i18nService.t("secureNotes");
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
this.groupingTitle = this.i18nService.t("sshKeys");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -429,6 +429,39 @@
|
||||
<div *ngIf="cipher.identity.country">{{ cipher.identity.country }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SshKey -->
|
||||
<div *ngIf="cipher.sshKey">
|
||||
<div class="box-content-row" *ngIf="cipher.sshKey.privateKey" style="overflow: hidden">
|
||||
<span
|
||||
class="row-label draggable"
|
||||
draggable="true"
|
||||
(dragstart)="setTextDataOnDrag($event, cipher.sshKey.privateKey)"
|
||||
>
|
||||
{{ "sshPrivateKey" | i18n }}
|
||||
</span>
|
||||
<div [innerText]="cipher.sshKey.maskedPrivateKey" class="monospaced"></div>
|
||||
</div>
|
||||
<div class="box-content-row" *ngIf="cipher.sshKey.publicKey" style="overflow: hidden">
|
||||
<span
|
||||
class="row-label draggable"
|
||||
draggable="true"
|
||||
(dragstart)="setTextDataOnDrag($event, cipher.sshKey.publicKey)"
|
||||
>
|
||||
{{ "sshPublicKey" | i18n }}</span
|
||||
>
|
||||
{{ cipher.sshKey.publicKey }}
|
||||
</div>
|
||||
<div class="box-content-row" *ngIf="cipher.sshKey.keyFingerprint" style="overflow: hidden">
|
||||
<span
|
||||
class="row-label draggable"
|
||||
draggable="true"
|
||||
(dragstart)="setTextDataOnDrag($event, cipher.sshKey.keyFingerprint)"
|
||||
>
|
||||
{{ "sshFingerprint" | i18n }}</span
|
||||
>
|
||||
{{ cipher.sshKey.keyFingerprint }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" *ngIf="cipher.login && cipher.login.hasUris">
|
||||
|
||||
@@ -202,6 +202,7 @@ describe("VaultPopupItemsService", () => {
|
||||
[CipherType.Card]: 2,
|
||||
[CipherType.Identity]: 3,
|
||||
[CipherType.SecureNote]: 4,
|
||||
[CipherType.SshKey]: 5,
|
||||
};
|
||||
|
||||
// Assume all ciphers are autofill ciphers to test sorting
|
||||
|
||||
@@ -277,6 +277,7 @@ export class VaultPopupItemsService {
|
||||
[CipherType.Card]: 2,
|
||||
[CipherType.Identity]: 3,
|
||||
[CipherType.SecureNote]: 4,
|
||||
[CipherType.SshKey]: 5,
|
||||
};
|
||||
|
||||
// Compare types first
|
||||
|
||||
@@ -97,6 +97,7 @@ describe("VaultPopupListFiltersService", () => {
|
||||
CipherType.Card,
|
||||
CipherType.Identity,
|
||||
CipherType.SecureNote,
|
||||
CipherType.SshKey,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,6 +163,11 @@ export class VaultPopupListFiltersService {
|
||||
label: this.i18nService.t("note"),
|
||||
icon: "bwi-sticky-note",
|
||||
},
|
||||
{
|
||||
value: CipherType.SshKey,
|
||||
label: this.i18nService.t("typeSshKey"),
|
||||
icon: "bwi-key",
|
||||
},
|
||||
];
|
||||
|
||||
/** Resets `filterForm` to the original state */
|
||||
|
||||
651
apps/desktop/desktop_native/Cargo.lock
generated
651
apps/desktop/desktop_native/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -27,21 +27,39 @@ anyhow = "=1.0.93"
|
||||
arboard = { version = "=3.4.1", default-features = false, features = [
|
||||
"wayland-data-control",
|
||||
] }
|
||||
async-stream = "0.3.5"
|
||||
base64 = "=0.22.1"
|
||||
byteorder = "1.5.0"
|
||||
cbc = { version = "=0.1.2", features = ["alloc"] }
|
||||
homedir = "0.3.3"
|
||||
libc = "=0.2.159"
|
||||
pin-project = "1.1.5"
|
||||
dirs = "=5.0.1"
|
||||
futures = "=0.3.31"
|
||||
interprocess = { version = "=2.2.1", features = ["tokio"] }
|
||||
libc = "=0.2.159"
|
||||
log = "=0.4.22"
|
||||
rand = "=0.8.5"
|
||||
retry = "=2.0.0"
|
||||
russh-cryptovec = "0.7.3"
|
||||
scopeguard = "=1.2.0"
|
||||
sha2 = "=0.10.8"
|
||||
ssh-encoding = "0.2.0"
|
||||
ssh-key = { version = "0.6.6", default-features = false, features = [
|
||||
"encryption",
|
||||
"ed25519",
|
||||
"rsa",
|
||||
"getrandom",
|
||||
] }
|
||||
bitwarden-russh = { git = "https://github.com/bitwarden/bitwarden-russh.git", branch = "km/pm-10098/clean-russh-implementation" }
|
||||
tokio = { version = "=1.40.0", features = ["io-util", "sync", "macros", "net"] }
|
||||
tokio-stream = { version = "=0.1.15", features = ["net"] }
|
||||
tokio-util = "=0.7.11"
|
||||
thiserror = "=1.0.68"
|
||||
tokio = { version = "=1.41.0", features = ["io-util", "sync", "macros"] }
|
||||
tokio-util = "=0.7.12"
|
||||
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"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
widestring = { version = "=1.1.0", optional = true }
|
||||
@@ -61,9 +79,9 @@ windows = { version = "=0.57.0", features = [
|
||||
keytar = "=0.1.6"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = { version = "=0.9.4", optional = true }
|
||||
security-framework = { version = "=2.11.0", optional = true }
|
||||
security-framework-sys = { version = "=2.11.0", optional = true }
|
||||
core-foundation = { version = "=0.10.0", optional = true }
|
||||
security-framework = { version = "=3.0.0", optional = true }
|
||||
security-framework-sys = { version = "=2.12.0", optional = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gio = { version = "=0.19.5", optional = true }
|
||||
|
||||
@@ -6,8 +6,8 @@ use anyhow::{anyhow, Result};
|
||||
#[cfg_attr(target_os = "macos", path = "macos.rs")]
|
||||
mod biometric;
|
||||
|
||||
pub use biometric::Biometric;
|
||||
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
|
||||
pub use biometric::Biometric;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::crypto::{self, CipherString};
|
||||
@@ -42,7 +42,6 @@ pub trait BiometricTrait {
|
||||
) -> Result<String>;
|
||||
}
|
||||
|
||||
|
||||
fn encrypt(secret: &str, key_material: &KeyMaterial, iv_b64: &str) -> Result<String> {
|
||||
let iv = base64_engine
|
||||
.decode(iv_b64)?
|
||||
@@ -77,4 +76,4 @@ impl KeyMaterial {
|
||||
pub fn derive_key(&self) -> Result<GenericArray<u8, typenum::U32>> {
|
||||
Ok(Sha256::digest(self.digest_material()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ use base64::Engine;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::biometric::{KeyMaterial, OsDerivedKey, base64_engine};
|
||||
use crate::biometric::{base64_engine, KeyMaterial, OsDerivedKey};
|
||||
use zbus::Connection;
|
||||
use zbus_polkit::policykit1::*;
|
||||
|
||||
use super::{decrypt, encrypt};
|
||||
use anyhow::anyhow;
|
||||
use crate::crypto::CipherString;
|
||||
use anyhow::anyhow;
|
||||
|
||||
/// The Unix implementation of the biometric trait.
|
||||
pub struct Biometric {}
|
||||
@@ -22,13 +22,15 @@ impl super::BiometricTrait for Biometric {
|
||||
let proxy = AuthorityProxy::new(&connection).await?;
|
||||
let subject = Subject::new_for_owner(std::process::id(), None, None)?;
|
||||
let details = std::collections::HashMap::new();
|
||||
let result = proxy.check_authorization(
|
||||
&subject,
|
||||
"com.bitwarden.Bitwarden.unlock",
|
||||
&details,
|
||||
CheckAuthorizationFlags::AllowUserInteraction.into(),
|
||||
"",
|
||||
).await;
|
||||
let result = proxy
|
||||
.check_authorization(
|
||||
&subject,
|
||||
"com.bitwarden.Bitwarden.unlock",
|
||||
&details,
|
||||
CheckAuthorizationFlags::AllowUserInteraction.into(),
|
||||
"",
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(result) => {
|
||||
@@ -106,4 +108,4 @@ fn random_challenge() -> [u8; 16] {
|
||||
let mut challenge = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut challenge);
|
||||
challenge
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,6 @@ impl super::BiometricTrait for Biometric {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fn random_challenge() -> [u8; 16] {
|
||||
let mut challenge = [0u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut challenge);
|
||||
|
||||
@@ -11,3 +11,6 @@ pub mod password;
|
||||
pub mod process_isolation;
|
||||
#[cfg(feature = "sys")]
|
||||
pub mod powermonitor;
|
||||
#[cfg(feature = "sys")]
|
||||
|
||||
pub mod ssh_agent;
|
||||
|
||||
@@ -41,7 +41,11 @@ pub fn delete_password(service: &str, account: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
pub fn is_available() -> Result<bool> {
|
||||
let result = password_clear_sync(Some(&get_schema()), build_attributes("bitwardenSecretsAvailabilityTest", "test"), gio::Cancellable::NONE);
|
||||
let result = password_clear_sync(
|
||||
Some(&get_schema()),
|
||||
build_attributes("bitwardenSecretsAvailabilityTest", "test"),
|
||||
gio::Cancellable::NONE,
|
||||
);
|
||||
match result {
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use zbus::{Connection, MatchRule, export::futures_util::TryStreamExt};
|
||||
use zbus::{export::futures_util::TryStreamExt, Connection, MatchRule};
|
||||
struct ScreenLock {
|
||||
interface: Cow<'static, str>,
|
||||
path: Cow<'static, str>,
|
||||
@@ -42,7 +42,15 @@ pub async fn on_lock(tx: tokio::sync::mpsc::Sender<()>) -> Result<(), Box<dyn st
|
||||
pub async fn is_lock_monitor_available() -> bool {
|
||||
let connection = Connection::session().await.unwrap();
|
||||
for monitor in SCREEN_LOCK_MONITORS {
|
||||
let res = connection.call_method(Some(monitor.interface.clone()), monitor.path.clone(), Some(monitor.interface.clone()), "GetActive", &()).await;
|
||||
let res = connection
|
||||
.call_method(
|
||||
Some(monitor.interface.clone()),
|
||||
monitor.path.clone(),
|
||||
Some(monitor.interface.clone()),
|
||||
"GetActive",
|
||||
&(),
|
||||
)
|
||||
.await;
|
||||
if res.is_ok() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use libc::{c_int, self};
|
||||
#[cfg(target_env = "gnu")]
|
||||
use libc::c_uint;
|
||||
use libc::{self, c_int};
|
||||
|
||||
// RLIMIT_CORE is the maximum size of a core dump file. Setting both to 0 disables core dumps, on crashes
|
||||
// https://github.com/torvalds/linux/blob/1613e604df0cd359cf2a7fbd9be7a0bcfacfabd0/include/uapi/asm-generic/resource.h#L20
|
||||
@@ -22,7 +22,10 @@ pub fn disable_coredumps() -> Result<()> {
|
||||
};
|
||||
if unsafe { libc::setrlimit(RLIMIT_CORE, &rlimit) } != 0 {
|
||||
let e = std::io::Error::last_os_error();
|
||||
return Err(anyhow::anyhow!("failed to disable core dumping, memory might be persisted to disk on crashes {}", e))
|
||||
return Err(anyhow::anyhow!(
|
||||
"failed to disable core dumping, memory might be persisted to disk on crashes {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -35,7 +38,7 @@ pub fn is_core_dumping_disabled() -> Result<bool> {
|
||||
};
|
||||
if unsafe { libc::getrlimit(RLIMIT_CORE, &mut rlimit) } != 0 {
|
||||
let e = std::io::Error::last_os_error();
|
||||
return Err(anyhow::anyhow!("failed to get core dump limit {}", e))
|
||||
return Err(anyhow::anyhow!("failed to get core dump limit {}", e));
|
||||
}
|
||||
|
||||
Ok(rlimit.rlim_cur == 0 && rlimit.rlim_max == 0)
|
||||
@@ -44,7 +47,10 @@ pub fn is_core_dumping_disabled() -> Result<bool> {
|
||||
pub fn disable_memory_access() -> Result<()> {
|
||||
if unsafe { libc::prctl(PR_SET_DUMPABLE, 0) } != 0 {
|
||||
let e = std::io::Error::last_os_error();
|
||||
return Err(anyhow::anyhow!("failed to disable memory dumping, memory is dumpable by other processes {}", e))
|
||||
return Err(anyhow::anyhow!(
|
||||
"failed to disable memory dumping, memory is dumpable by other processes {}",
|
||||
e
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
45
apps/desktop/desktop_native/core/src/ssh_agent/generator.rs
Normal file
45
apps/desktop/desktop_native/core/src/ssh_agent/generator.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
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)
|
||||
.or_else(|e| Err(anyhow::anyhow!(e.to_string())))?;
|
||||
|
||||
let private_key = ssh_key::PrivateKey::new(
|
||||
ssh_key::private::KeypairData::from(rsa_keypair),
|
||||
"".to_string(),
|
||||
)
|
||||
.or_else(|e| Err(anyhow::anyhow!(e.to_string())))?;
|
||||
Ok(private_key)
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!("Unsupported key algorithm"));
|
||||
}
|
||||
}
|
||||
.or_else(|e| Err(anyhow::anyhow!(e.to_string())))?;
|
||||
|
||||
let private_key_openssh = key
|
||||
.to_openssh(LineEnding::LF)
|
||||
.or_else(|e| Err(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(),
|
||||
})
|
||||
}
|
||||
395
apps/desktop/desktop_native/core/src/ssh_agent/importer.rs
Normal file
395
apps/desktop/desktop_native/core/src/ssh_agent/importer.rs
Normal file
@@ -0,0 +1,395 @@
|
||||
use ed25519;
|
||||
use pkcs8::{
|
||||
der::Decode, EncryptedPrivateKeyInfo, ObjectIdentifier, PrivateKeyInfo, SecretDocument,
|
||||
};
|
||||
use ssh_key::{
|
||||
private::{Ed25519Keypair, Ed25519PrivateKey, RsaKeypair},
|
||||
HashAlg, LineEnding,
|
||||
};
|
||||
|
||||
const PKCS1_HEADER: &str = "-----BEGIN RSA PRIVATE KEY-----";
|
||||
const PKCS8_UNENCRYPTED_HEADER: &str = "-----BEGIN PRIVATE KEY-----";
|
||||
const PKCS8_ENCRYPTED_HEADER: &str = "-----BEGIN ENCRYPTED PRIVATE KEY-----";
|
||||
const OPENSSH_HEADER: &str = "-----BEGIN OPENSSH PRIVATE KEY-----";
|
||||
|
||||
pub const RSA_PKCS8_ALGORITHM_OID: ObjectIdentifier =
|
||||
ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.1");
|
||||
|
||||
#[derive(Debug)]
|
||||
enum KeyType {
|
||||
Ed25519,
|
||||
Rsa,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
pub fn import_key(
|
||||
encoded_key: String,
|
||||
password: String,
|
||||
) -> Result<SshKeyImportResult, anyhow::Error> {
|
||||
match encoded_key.lines().next() {
|
||||
Some(PKCS1_HEADER) => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::UnsupportedKeyType,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
Some(PKCS8_UNENCRYPTED_HEADER) => {
|
||||
return match import_pkcs8_key(encoded_key, None) {
|
||||
Ok(result) => Ok(result),
|
||||
Err(_) => Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Some(PKCS8_ENCRYPTED_HEADER) => match import_pkcs8_key(encoded_key, Some(password)) {
|
||||
Ok(result) => {
|
||||
return Ok(result);
|
||||
}
|
||||
Err(err) => match err {
|
||||
SshKeyImportError::PasswordRequired => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::PasswordRequired,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
SshKeyImportError::WrongPassword => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::WrongPassword,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
SshKeyImportError::ParsingError => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
Some(OPENSSH_HEADER) => {
|
||||
return import_openssh_key(encoded_key, password);
|
||||
}
|
||||
Some(_) => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
None => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn import_pkcs8_key(
|
||||
encoded_key: String,
|
||||
password: Option<String>,
|
||||
) -> Result<SshKeyImportResult, SshKeyImportError> {
|
||||
let der = match SecretDocument::from_pem(&encoded_key) {
|
||||
Ok((_, doc)) => doc,
|
||||
Err(_) => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let decrypted_der = match password.clone() {
|
||||
Some(password) => {
|
||||
let encrypted_private_key_info = match EncryptedPrivateKeyInfo::from_der(der.as_bytes())
|
||||
{
|
||||
Ok(info) => info,
|
||||
Err(_) => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
match encrypted_private_key_info.decrypt(password.as_bytes()) {
|
||||
Ok(der) => der,
|
||||
Err(_) => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::WrongPassword,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None => der,
|
||||
};
|
||||
|
||||
let key_type: KeyType = match PrivateKeyInfo::from_der(decrypted_der.as_bytes())
|
||||
.map_err(|_| SshKeyImportError::ParsingError)?
|
||||
.algorithm
|
||||
.oid
|
||||
{
|
||||
ed25519::pkcs8::ALGORITHM_OID => KeyType::Ed25519,
|
||||
RSA_PKCS8_ALGORITHM_OID => KeyType::Rsa,
|
||||
_ => KeyType::Unknown,
|
||||
};
|
||||
|
||||
match key_type {
|
||||
KeyType::Ed25519 => {
|
||||
let pk: ed25519::KeypairBytes = match password {
|
||||
Some(password) => {
|
||||
pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password)
|
||||
.map_err(|err| match err {
|
||||
ed25519::pkcs8::Error::EncryptedPrivateKey(_) => {
|
||||
SshKeyImportError::WrongPassword
|
||||
}
|
||||
_ => SshKeyImportError::ParsingError,
|
||||
})?
|
||||
}
|
||||
None => ed25519::pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key)
|
||||
.map_err(|_| SshKeyImportError::ParsingError)?,
|
||||
};
|
||||
let pk: Ed25519Keypair =
|
||||
Ed25519Keypair::from(Ed25519PrivateKey::from_bytes(&pk.secret_key));
|
||||
let private_key = ssh_key::private::PrivateKey::from(pk);
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::Success,
|
||||
ssh_key: Some(SshKey {
|
||||
private_key: private_key.to_openssh(LineEnding::LF).unwrap().to_string(),
|
||||
public_key: private_key.public_key().to_string(),
|
||||
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
KeyType::Rsa => {
|
||||
let pk: rsa::RsaPrivateKey = match password {
|
||||
Some(password) => {
|
||||
pkcs8::DecodePrivateKey::from_pkcs8_encrypted_pem(&encoded_key, password)
|
||||
.map_err(|err| match err {
|
||||
pkcs8::Error::EncryptedPrivateKey(_) => {
|
||||
SshKeyImportError::WrongPassword
|
||||
}
|
||||
_ => SshKeyImportError::ParsingError,
|
||||
})?
|
||||
}
|
||||
None => pkcs8::DecodePrivateKey::from_pkcs8_pem(&encoded_key)
|
||||
.map_err(|_| SshKeyImportError::ParsingError)?,
|
||||
};
|
||||
let rsa_keypair: Result<RsaKeypair, ssh_key::Error> = RsaKeypair::try_from(pk);
|
||||
match rsa_keypair {
|
||||
Ok(rsa_keypair) => {
|
||||
let private_key = ssh_key::private::PrivateKey::from(rsa_keypair);
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::Success,
|
||||
ssh_key: Some(SshKey {
|
||||
private_key: private_key
|
||||
.to_openssh(LineEnding::LF)
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
public_key: private_key.public_key().to_string(),
|
||||
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::UnsupportedKeyType,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn import_openssh_key(
|
||||
encoded_key: String,
|
||||
password: String,
|
||||
) -> Result<SshKeyImportResult, anyhow::Error> {
|
||||
let private_key = ssh_key::private::PrivateKey::from_openssh(&encoded_key);
|
||||
let private_key = match private_key {
|
||||
Ok(k) => k,
|
||||
Err(err) => {
|
||||
match err {
|
||||
ssh_key::Error::AlgorithmUnknown
|
||||
| ssh_key::Error::AlgorithmUnsupported { algorithm: _ } => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::UnsupportedKeyType,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if private_key.is_encrypted() && password.is_empty() {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::PasswordRequired,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
let private_key = if private_key.is_encrypted() {
|
||||
match private_key.decrypt(password.as_bytes()) {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
return Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::WrongPassword,
|
||||
ssh_key: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
private_key
|
||||
};
|
||||
|
||||
match private_key.to_openssh(LineEnding::LF) {
|
||||
Ok(private_key_openssh) => Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::Success,
|
||||
ssh_key: Some(SshKey {
|
||||
private_key: private_key_openssh.to_string(),
|
||||
public_key: private_key.public_key().to_string(),
|
||||
key_fingerprint: private_key.fingerprint(HashAlg::Sha256).to_string(),
|
||||
}),
|
||||
}),
|
||||
Err(_) => Ok(SshKeyImportResult {
|
||||
status: SshKeyImportStatus::ParsingError,
|
||||
ssh_key: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Debug)]
|
||||
pub enum SshKeyImportStatus {
|
||||
/// ssh key was parsed correctly and will be returned in the result
|
||||
Success,
|
||||
/// ssh key was parsed correctly but is encrypted and requires a password
|
||||
PasswordRequired,
|
||||
/// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect
|
||||
WrongPassword,
|
||||
/// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key
|
||||
ParsingError,
|
||||
/// ssh key type is not supported
|
||||
UnsupportedKeyType,
|
||||
}
|
||||
|
||||
pub enum SshKeyImportError {
|
||||
ParsingError,
|
||||
PasswordRequired,
|
||||
WrongPassword,
|
||||
}
|
||||
|
||||
pub struct SshKeyImportResult {
|
||||
pub status: SshKeyImportStatus,
|
||||
pub ssh_key: Option<SshKey>,
|
||||
}
|
||||
|
||||
pub struct SshKey {
|
||||
pub private_key: String,
|
||||
pub public_key: String,
|
||||
pub key_fingerprint: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn import_key_ed25519_openssh_unencrypted() {
|
||||
let private_key = include_str!("./test_keys/ed25519_openssh_unencrypted");
|
||||
let public_key = include_str!("./test_keys/ed25519_openssh_unencrypted.pub").trim();
|
||||
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::Success);
|
||||
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_key_ed25519_openssh_encrypted() {
|
||||
let private_key = include_str!("./test_keys/ed25519_openssh_encrypted");
|
||||
let public_key = include_str!("./test_keys/ed25519_openssh_encrypted.pub").trim();
|
||||
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::Success);
|
||||
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_key_rsa_openssh_unencrypted() {
|
||||
let private_key = include_str!("./test_keys/rsa_openssh_unencrypted");
|
||||
let public_key = include_str!("./test_keys/rsa_openssh_unencrypted.pub").trim();
|
||||
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::Success);
|
||||
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_key_rsa_openssh_encrypted() {
|
||||
let private_key = include_str!("./test_keys/rsa_openssh_encrypted");
|
||||
let public_key = include_str!("./test_keys/rsa_openssh_encrypted.pub").trim();
|
||||
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::Success);
|
||||
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_key_ed25519_pkcs8_unencrypted() {
|
||||
let private_key = include_str!("./test_keys/ed25519_pkcs8_unencrypted");
|
||||
let public_key =
|
||||
include_str!("./test_keys/ed25519_pkcs8_unencrypted.pub").replace("testkey", "");
|
||||
let public_key = public_key.trim();
|
||||
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::Success);
|
||||
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_key_rsa_pkcs8_unencrypted() {
|
||||
let private_key = include_str!("./test_keys/rsa_pkcs8_unencrypted");
|
||||
// for whatever reason pkcs8 + rsa does not include the comment in the public key
|
||||
let public_key =
|
||||
include_str!("./test_keys/rsa_pkcs8_unencrypted.pub").replace("testkey", "");
|
||||
let public_key = public_key.trim();
|
||||
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::Success);
|
||||
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_key_rsa_pkcs8_encrypted() {
|
||||
let private_key = include_str!("./test_keys/rsa_pkcs8_encrypted");
|
||||
let public_key = include_str!("./test_keys/rsa_pkcs8_encrypted.pub").replace("testkey", "");
|
||||
let public_key = public_key.trim();
|
||||
let result = import_key(private_key.to_string(), "password".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::Success);
|
||||
assert_eq!(result.ssh_key.unwrap().public_key, public_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_key_ed25519_openssh_encrypted_wrong_password() {
|
||||
let private_key = include_str!("./test_keys/ed25519_openssh_encrypted");
|
||||
let result = import_key(private_key.to_string(), "wrongpassword".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::WrongPassword);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_non_key_error() {
|
||||
let result = import_key("not a key".to_string(), "".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::ParsingError);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_ecdsa_error() {
|
||||
let private_key = include_str!("./test_keys/ecdsa_openssh_unencrypted");
|
||||
let result = import_key(private_key.to_string(), "".to_string()).unwrap();
|
||||
assert_eq!(result.status, SshKeyImportStatus::UnsupportedKeyType);
|
||||
}
|
||||
}
|
||||
118
apps/desktop/desktop_native/core/src/ssh_agent/mod.rs
Normal file
118
apps/desktop/desktop_native/core/src/ssh_agent/mod.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use bitwarden_russh::ssh_agent::{self, Key};
|
||||
|
||||
#[cfg_attr(target_os = "windows", path = "windows.rs")]
|
||||
#[cfg_attr(target_os = "macos", path = "unix.rs")]
|
||||
#[cfg_attr(target_os = "linux", path = "unix.rs")]
|
||||
mod platform_ssh_agent;
|
||||
|
||||
pub mod generator;
|
||||
pub mod importer;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BitwardenDesktopAgent {
|
||||
keystore: ssh_agent::KeyStore,
|
||||
cancellation_token: CancellationToken,
|
||||
show_ui_request_tx: tokio::sync::mpsc::Sender<(u32, String)>,
|
||||
get_ui_response_rx: Arc<Mutex<tokio::sync::broadcast::Receiver<(u32, bool)>>>,
|
||||
request_id: Arc<Mutex<u32>>,
|
||||
}
|
||||
|
||||
impl BitwardenDesktopAgent {
|
||||
async fn get_request_id(&self) -> u32 {
|
||||
let mut request_id = self.request_id.lock().await;
|
||||
*request_id += 1;
|
||||
*request_id
|
||||
}
|
||||
}
|
||||
|
||||
impl ssh_agent::Agent for BitwardenDesktopAgent {
|
||||
async fn confirm(&self, ssh_key: Key) -> bool {
|
||||
let request_id = self.get_request_id().await;
|
||||
|
||||
let mut rx_channel = self.get_ui_response_rx.lock().await.resubscribe();
|
||||
self.show_ui_request_tx
|
||||
.send((request_id, ssh_key.cipher_uuid.clone()))
|
||||
.await
|
||||
.expect("Should send request to ui");
|
||||
while let Ok((id, response)) = rx_channel.recv().await {
|
||||
if id == request_id {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl BitwardenDesktopAgent {
|
||||
pub fn stop(&self) {
|
||||
self.cancellation_token.cancel();
|
||||
self.keystore
|
||||
.0
|
||||
.write()
|
||||
.expect("RwLock is not poisoned")
|
||||
.clear();
|
||||
}
|
||||
|
||||
pub fn set_keys(
|
||||
&mut self,
|
||||
new_keys: Vec<(String, String, String)>,
|
||||
) -> Result<(), anyhow::Error> {
|
||||
let keystore = &mut self.keystore;
|
||||
keystore.0.write().expect("RwLock is not poisoned").clear();
|
||||
|
||||
for (key, name, cipher_id) in new_keys.iter() {
|
||||
match parse_key_safe(&key) {
|
||||
Ok(private_key) => {
|
||||
let public_key_bytes = private_key
|
||||
.public_key()
|
||||
.to_bytes()
|
||||
.expect("Cipher private key is always correctly parsed");
|
||||
keystore.0.write().expect("RwLock is not poisoned").insert(
|
||||
public_key_bytes,
|
||||
Key {
|
||||
private_key: Some(private_key),
|
||||
name: name.clone(),
|
||||
cipher_uuid: cipher_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[SSH Agent Native Module] Error while parsing key: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn lock(&mut self) -> Result<(), anyhow::Error> {
|
||||
let keystore = &mut self.keystore;
|
||||
keystore
|
||||
.0
|
||||
.write()
|
||||
.expect("RwLock is not poisoned")
|
||||
.iter_mut()
|
||||
.for_each(|(_public_key, key)| {
|
||||
key.private_key = None;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_key_safe(pem: &str) -> Result<ssh_key::private::PrivateKey, anyhow::Error> {
|
||||
match ssh_key::private::PrivateKey::from_openssh(pem) {
|
||||
Ok(key) => match key.public_key().to_bytes() {
|
||||
Ok(_) => Ok(key),
|
||||
Err(e) => Err(anyhow::Error::msg(format!(
|
||||
"Failed to parse public key: {}",
|
||||
e
|
||||
))),
|
||||
},
|
||||
Err(e) => Err(anyhow::Error::msg(format!("Failed to parse key: {}", e))),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use std::{
|
||||
io,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use futures::Stream;
|
||||
use tokio::{
|
||||
net::windows::named_pipe::{NamedPipeServer, ServerOptions},
|
||||
select,
|
||||
};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const PIPE_NAME: &str = r"\\.\pipe\openssh-ssh-agent";
|
||||
|
||||
#[pin_project::pin_project]
|
||||
pub struct NamedPipeServerStream {
|
||||
rx: tokio::sync::mpsc::Receiver<NamedPipeServer>,
|
||||
}
|
||||
|
||||
impl NamedPipeServerStream {
|
||||
pub fn new(cancellation_token: CancellationToken) -> Self {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(16);
|
||||
tokio::spawn(async move {
|
||||
println!(
|
||||
"[SSH Agent Native Module] Creating named pipe server on {}",
|
||||
PIPE_NAME
|
||||
);
|
||||
let mut listener = ServerOptions::new().create(PIPE_NAME).unwrap();
|
||||
loop {
|
||||
println!("[SSH Agent Native Module] Waiting for connection");
|
||||
select! {
|
||||
_ = cancellation_token.cancelled() => {
|
||||
println!("[SSH Agent Native Module] Cancellation token triggered, stopping named pipe server");
|
||||
break;
|
||||
}
|
||||
_ = listener.connect() => {
|
||||
println!("[SSH Agent Native Module] Incoming connection");
|
||||
tx.send(listener).await.unwrap();
|
||||
listener = ServerOptions::new().create(PIPE_NAME).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { rx }
|
||||
}
|
||||
}
|
||||
|
||||
impl Stream for NamedPipeServerStream {
|
||||
type Item = io::Result<NamedPipeServer>;
|
||||
|
||||
fn poll_next(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
) -> Poll<Option<io::Result<NamedPipeServer>>> {
|
||||
let this = self.project();
|
||||
|
||||
this.rx.poll_recv(cx).map(|v| v.map(Ok))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
|
||||
1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQRQzzQ8nQEouF1FMSHkPx1nejNCzF7g
|
||||
Yb8MHXLdBFM0uJkWs0vzgLJkttts2eDv3SHJqIH6qHpkLtEvgMXE5WcaAAAAoOO1BebjtQ
|
||||
XmAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFDPNDydASi4XUUx
|
||||
IeQ/HWd6M0LMXuBhvwwdct0EUzS4mRazS/OAsmS222zZ4O/dIcmogfqoemQu0S+AxcTlZx
|
||||
oAAAAhAKnIXk6H0Hs3HblklaZ6UmEjjdE/0t7EdYixpMmtpJ4eAAAAB3Rlc3RrZXk=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFDPNDydASi4XUUxIeQ/HWd6M0LMXuBhvwwdct0EUzS4mRazS/OAsmS222zZ4O/dIcmogfqoemQu0S+AxcTlZxo= testkey
|
||||
@@ -0,0 +1,8 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABAUTNb0if
|
||||
fqsoqtfv70CfukAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIHGs3Uw3eyqnFjBI
|
||||
2eb7Qto4KVc34ZdnBac59Bab54BLAAAAkPA6aovfxQbP6FoOfaRH6u22CxqiUM0bbMpuFf
|
||||
WETn9FLaBE6LjoHH0ZI5rzNjJaQUNfx0cRcqsIrexw8YINrdVjySmEqrl5hw8gpgy0gGP5
|
||||
1Y6vKWdHdrxJCA9YMFOfDs0UhPfpLKZCwm2Sg+Bd8arlI8Gy7y4Jj/60v2bZOLhD2IZQnK
|
||||
NdJ8xATiIINuTy4g==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHGs3Uw3eyqnFjBI2eb7Qto4KVc34ZdnBac59Bab54BL testkey
|
||||
@@ -0,0 +1,7 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||
QyNTUxOQAAACAyQo22TXXNqvF+L8jUSSNeu8UqrsDjvf9pwIwDC9ML6gAAAJDSHpL60h6S
|
||||
+gAAAAtzc2gtZWQyNTUxOQAAACAyQo22TXXNqvF+L8jUSSNeu8UqrsDjvf9pwIwDC9ML6g
|
||||
AAAECLdlFLIJbEiFo/f0ROdXMNZAPHGPNhvbbftaPsUZEjaDJCjbZNdc2q8X4vyNRJI167
|
||||
xSquwOO9/2nAjAML0wvqAAAAB3Rlc3RrZXkBAgMEBQY=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDJCjbZNdc2q8X4vyNRJI167xSquwOO9/2nAjAML0wvq testkey
|
||||
@@ -0,0 +1,4 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MFECAQEwBQYDK2VwBCIEIDY6/OAdDr3PbDss9NsLXK4CxiKUvz5/R9uvjtIzj4Sz
|
||||
gSEAxsxm1xpZ/4lKIRYm0JrJ5gRZUh7H24/YT/0qGVGzPa0=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMbMZtcaWf+JSiEWJtCayeYEWVIex9uP2E/9KhlRsz2t
|
||||
@@ -0,0 +1,39 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
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
|
||||
2MmKBw==
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC/v18xGP3qzRV9iWqyiuwHZ4GpC4K2NO2/i2Yv5A3/bnal7CmiMh/S78lphgxcWtFkwrwlb321FmdHBv6KOW+EzSiPvmsdkkbpfBXB3Qf2SlhZOZZ7lYeu8KAxL3exvvn8O1GGlUjXGUrFgmC60tHWDBc1Ncmo8a2dwDLmA/sbLa8su2dvYEFmRg1vaytLDpkn8GS7zAxrUl/g0W2RwkPsByduUziQuX90v9WAy7MqOlwBRq6t5o8wdDBVODe0VIXC7N1OS42YUsKF+N0XOnLiJrIIKkXpahMDpKZHeHQAdUQzsJVhKoLJR8DNDTYyhnJoQG7Q6m2gDTca9oAWvsBiNoEwCvwrt7cDNCz/GslH9HXQgfWcVXn8+fuZgvjO3CxUI16Ev33m0jWoOKJcgK/ZLRnk8SEvsJ8NO32MeR/qUb7IN/yUcDmPMI/3ecQsakF2cwNzHkyiGVo//yVTpf+vk8b89L+GXbYU5rtswtc2ZEGsQnUkaoNqS8mHqhWQBUk= testkey
|
||||
@@ -0,0 +1,38 @@
|
||||
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
|
||||
NhAAAAAwEAAQAAAYEAtVIe0gnPtD6299/roT7ntZgVe+qIqIMIruJdI2xTanLGhNpBOlzg
|
||||
WqokbQK+aXATcaB7iQL1SPxIWV2M4jEBQbZuimIgDQvKbJ4TZPKEe1VdsrfuIo+9pDK7cG
|
||||
Kc+JiWhKjqeTRMj91/qR1fW5IWOUyE1rkwhTNkwJqtYKZLVmd4TXtQsYMMC+I0cz4krfk1
|
||||
Yqmaae/gj12h8BvE3Y+Koof4JoLsqPufH+H/bVEayv63RyAQ1/tUv9l+rwJ+svWV4X3zf3
|
||||
z40hGF43L/NGl90Vutbn7b9G/RgEdiXyLZciP3XbWbLUM+r7mG9KNuSeoixe5jok15UKqC
|
||||
XXxVb5IEZ73kaubSfz9JtsqtKG/OjOq6Fbl3Ky7kjvJyGpIvesuSInlpzPXqbLUCLJJfOA
|
||||
PUZ1wi8uuuRNePzQBMMhq8UtAbB2Dy16d+HlgghzQ00NxtbQMfDZBdApfxm3shIxkUcHzb
|
||||
DSvriHVaGGoOkmHPAmsdMsMiekuUMe9ljdOhmdTxAAAFgF8XjBxfF4wcAAAAB3NzaC1yc2
|
||||
EAAAGBALVSHtIJz7Q+tvff66E+57WYFXvqiKiDCK7iXSNsU2pyxoTaQTpc4FqqJG0Cvmlw
|
||||
E3Gge4kC9Uj8SFldjOIxAUG2bopiIA0LymyeE2TyhHtVXbK37iKPvaQyu3BinPiYloSo6n
|
||||
k0TI/df6kdX1uSFjlMhNa5MIUzZMCarWCmS1ZneE17ULGDDAviNHM+JK35NWKpmmnv4I9d
|
||||
ofAbxN2PiqKH+CaC7Kj7nx/h/21RGsr+t0cgENf7VL/Zfq8CfrL1leF98398+NIRheNy/z
|
||||
RpfdFbrW5+2/Rv0YBHYl8i2XIj9121my1DPq+5hvSjbknqIsXuY6JNeVCqgl18VW+SBGe9
|
||||
5Grm0n8/SbbKrShvzozquhW5dysu5I7ychqSL3rLkiJ5acz16my1AiySXzgD1GdcIvLrrk
|
||||
TXj80ATDIavFLQGwdg8tenfh5YIIc0NNDcbW0DHw2QXQKX8Zt7ISMZFHB82w0r64h1Whhq
|
||||
DpJhzwJrHTLDInpLlDHvZY3ToZnU8QAAAAMBAAEAAAGAEL3wpRWtVTf+NnR5QgX4KJsOjs
|
||||
bI0ABrVpSFo43uxNMss9sgLzagq5ZurxcUBFHKJdF63puEkPTkbEX4SnFaa5of6kylp3a5
|
||||
fd55rXY8F9Q5xtT3Wr8ZdFYP2xBr7INQUJb1MXRMBnOeBDw3UBH01d0UHexzB7WHXcZacG
|
||||
Ria+u5XrQebwmJ3PYJwENSaTLrxDyjSplQy4QKfgxeWNPWaevylIG9vtue5Xd9WXdl6Szs
|
||||
ONfD3mFxQZagPSIWl0kYIjS3P2ZpLe8+sakRcfci8RjEUP7U+QxqY5VaQScjyX1cSYeQLz
|
||||
t+/6Tb167aNtQ8CVW3IzM2EEN1BrSbVxFkxWFLxogAHct06Kn87nPn2+PWGWOVCBp9KheO
|
||||
FszWAJ0Kzjmaga2BpOJcrwjSpGopAb1YPIoRPVepVZlQ4gGwy5gXCFwykT9WTBoJfg0BMQ
|
||||
r3MSNcoc97eBomIWEa34K0FuQ3rVjMv9ylfyLvDBbRqTJ5zebeOuU+yCQHZUKk8klRAAAA
|
||||
wAsToNZvYWRsOMTWQom0EW1IHzoL8Cyua+uh72zZi/7enm4yHPJiu2KNgQXfB0GEEjHjbo
|
||||
9peCW3gZGTV+Ee+cAqwYLlt0SMl/VJNxN3rEG7BAqPZb42Ii2XGjaxzFq0cliUGAdo6UEd
|
||||
swU8d2I7m9vIZm4nDXzsWOBWgonTKBNyL0DQ6KNOGEyj8W0BTCm7Rzwy7EKzFWbIxr4lSc
|
||||
vDrJ3t6kOd7jZTF58kRMT0nxR0bf43YzF/3/qSvLYhQm/OOAAAAMEA2F6Yp8SrpQDNDFxh
|
||||
gi4GeywArrQO9r3EHjnBZi/bacxllSzCGXAvp7m9OKC1VD2wQP2JL1VEIZRUTuGGT6itrm
|
||||
QpX8OgoxlEJrlC5W0kHumZ3MFGd33W11u37gOilmd6+VfVXBziNG2rFohweAgs8X+Sg5AA
|
||||
nIfMV6ySXUlvLzMHpGeKRRnnQq9Cwn4rDkVQENLd1i4e2nWFhaPTUwVMR8YuOT766bywr3
|
||||
7vG1PQLF7hnf2c/oPHAru+XD9gJWs5AAAAwQDWiB2G23F4Tvq8FiK2mMusSjQzHupl83rm
|
||||
o3BSNRCvCjaLx6bWhDPSA1edNEF7VuP6rSp+i+UfSORHwOnlgnrvtcJeoDuA72hUeYuqD/
|
||||
1C9gghdhKzGTVf/IGTX1tH3rn2Gq9TEyrJs/ITcoOyZprz7VbaD3bP/NEER+m1EHi2TS/3
|
||||
SXQEtRm+IIBwba+QLUcsrWdQyIO+1OCXywDrAw50s7tjgr/goHgXTcrSXaKcIEOlPgBZH3
|
||||
YPuVuEtRYgX3kAAAAHdGVzdGtleQECAwQ=
|
||||
-----END OPENSSH PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC1Uh7SCc+0Prb33+uhPue1mBV76oiogwiu4l0jbFNqcsaE2kE6XOBaqiRtAr5pcBNxoHuJAvVI/EhZXYziMQFBtm6KYiANC8psnhNk8oR7VV2yt+4ij72kMrtwYpz4mJaEqOp5NEyP3X+pHV9bkhY5TITWuTCFM2TAmq1gpktWZ3hNe1CxgwwL4jRzPiSt+TViqZpp7+CPXaHwG8Tdj4qih/gmguyo+58f4f9tURrK/rdHIBDX+1S/2X6vAn6y9ZXhffN/fPjSEYXjcv80aX3RW61uftv0b9GAR2JfItlyI/ddtZstQz6vuYb0o25J6iLF7mOiTXlQqoJdfFVvkgRnveRq5tJ/P0m2yq0ob86M6roVuXcrLuSO8nIaki96y5IieWnM9epstQIskl84A9RnXCLy665E14/NAEwyGrxS0BsHYPLXp34eWCCHNDTQ3G1tAx8NkF0Cl/GbeyEjGRRwfNsNK+uIdVoYag6SYc8Cax0ywyJ6S5Qx72WN06GZ1PE= testkey
|
||||
@@ -0,0 +1,42 @@
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
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
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCcHkc0xfH4w9aW41S9M/BfancSY4QPc2O4G1cRjFfK8QrLEGDA7NiHtoEML0afcurRXD3NVxuKaAns0w6EoS4CjzXUqVHTLA4SUyuapr8k0Eu2xOpbCwC3jDovhckoKloq7BvE6rC2i5wjSMadtIJKt/dqWI3HLjUMz1BxQJAU/qAbicj1SFZSjA/MubVBzcq93XOvByMtlIFu7wami3FTc37rVkGeUFHtK8ZbvG3n1aaTF79bBgSPuoq5BfcMdGr4WfQyGQzgse4v4hQ8yKYrtE0jo0kf06hEORimwOIU/W5IH1r+/xFs7qGKcPnFSZRIFv5LfMPTo8b+OsBRflosyfUumDEX97GZE7DSQl0EJzNvWeKwl7dQ8RUJTkbph2CjrxY77DFim+165Uj/WRr4uq2qMNhA2xNSD19+TA6AHdpGw4WZd37q2/n+EddlaJEH8MzpgtHNG9MiYh5ScZ+AG0QugflozJcQNc7n8N9Lpu1sRoejV5RhurHg/TYwVK8= testkey
|
||||
@@ -0,0 +1,40 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
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
|
||||
QjDEB37RqGZxqyIx8V2ZYwU=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCn4+QiJojZ9mgc9KYJIvDWGaz4qFhf0CButg6L8zEoHKwuiN+mqcEciCCOa9BNiJmm8NTTehZvrrglGG59zIbqYtDAHjVn+vtb49xPzIv+M651Yqj08lIbR9tEIHKCq7aH8GlDm8NgG9EzJGjlL7okQym4TH1MHl+s4mUyr/qb2unlZBDixAQsphU8iCLftukWCIkmQg4CSj1Gh3WbBlZ+EX5eW0EXuAw4XsSbBTWV9CHRowVIpYqPvEYSpHsoCjEcd988p19hpiGknA0J4z7JfUlNgyT/1chb8GCTDT+2DCBRApbsIg6TOBVS+PR6emAQ3eZzUW0+3/oRM4ip0ujltQy8uU6gvYIAqx5wXGMThVpZcUgahKiSsVo/s4b84iMe4DG3W8jz4qi6yyNv0VedEzPUZ1lXd1GJFoy9uKNuSTe+1ksicAcluZN6LuNsPHcPxFCzOcmoNnVXEKAXInt+ys//5CDVasroZSAHZnDjUD4oNsLI3VIOnGxgXrkwSH0= testkey
|
||||
77
apps/desktop/desktop_native/core/src/ssh_agent/unix.rs
Normal file
77
apps/desktop/desktop_native/core/src/ssh_agent/unix.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
use bitwarden_russh::ssh_agent;
|
||||
use homedir::my_home;
|
||||
use tokio::{net::UnixListener, sync::Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::BitwardenDesktopAgent;
|
||||
|
||||
impl BitwardenDesktopAgent {
|
||||
pub async fn start_server(
|
||||
auth_request_tx: tokio::sync::mpsc::Sender<(u32, String)>,
|
||||
auth_response_rx: Arc<Mutex<tokio::sync::broadcast::Receiver<(u32, bool)>>>,
|
||||
) -> Result<Self, anyhow::Error> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
let agent = BitwardenDesktopAgent {
|
||||
keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))),
|
||||
cancellation_token: CancellationToken::new(),
|
||||
show_ui_request_tx: auth_request_tx,
|
||||
get_ui_response_rx: auth_response_rx,
|
||||
request_id: Arc::new(tokio::sync::Mutex::new(0)),
|
||||
};
|
||||
let cloned_agent_state = agent.clone();
|
||||
tokio::spawn(async move {
|
||||
let ssh_path = match std::env::var("BITWARDEN_SSH_AUTH_SOCK") {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
println!("[SSH Agent Native Module] BITWARDEN_SSH_AUTH_SOCK not set, using default path");
|
||||
|
||||
let ssh_agent_directory = match my_home() {
|
||||
Ok(Some(home)) => home,
|
||||
_ => PathBuf::from("/tmp/"),
|
||||
};
|
||||
ssh_agent_directory
|
||||
.join(".bitwarden-ssh-agent.sock")
|
||||
.to_str()
|
||||
.expect("Path should be valid")
|
||||
.to_owned()
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"[SSH Agent Native Module] Starting SSH Agent server on {:?}",
|
||||
ssh_path
|
||||
);
|
||||
let sockname = std::path::Path::new(&ssh_path);
|
||||
let _ = std::fs::remove_file(sockname);
|
||||
match UnixListener::bind(sockname) {
|
||||
Ok(listener) => {
|
||||
let wrapper = tokio_stream::wrappers::UnixListenerStream::new(listener);
|
||||
let cloned_keystore = cloned_agent_state.keystore.clone();
|
||||
let cloned_cancellation_token = cloned_agent_state.cancellation_token.clone();
|
||||
let _ = ssh_agent::serve(
|
||||
wrapper,
|
||||
cloned_agent_state,
|
||||
cloned_keystore,
|
||||
cloned_cancellation_token,
|
||||
)
|
||||
.await;
|
||||
println!("[SSH Agent Native Module] SSH Agent server exited");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[SSH Agent Native Module] Error while starting agent server: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(agent)
|
||||
}
|
||||
}
|
||||
41
apps/desktop/desktop_native/core/src/ssh_agent/windows.rs
Normal file
41
apps/desktop/desktop_native/core/src/ssh_agent/windows.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use bitwarden_russh::ssh_agent;
|
||||
pub mod named_pipe_listener_stream;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::BitwardenDesktopAgent;
|
||||
|
||||
impl BitwardenDesktopAgent {
|
||||
pub async fn start_server(
|
||||
auth_request_tx: tokio::sync::mpsc::Sender<(u32, String)>,
|
||||
auth_response_rx: Arc<Mutex<tokio::sync::broadcast::Receiver<(u32, bool)>>>,
|
||||
) -> Result<Self, anyhow::Error> {
|
||||
let agent_state = BitwardenDesktopAgent {
|
||||
keystore: ssh_agent::KeyStore(Arc::new(RwLock::new(HashMap::new()))),
|
||||
show_ui_request_tx: auth_request_tx,
|
||||
get_ui_response_rx: auth_response_rx,
|
||||
cancellation_token: CancellationToken::new(),
|
||||
request_id: Arc::new(tokio::sync::Mutex::new(0)),
|
||||
};
|
||||
let stream = named_pipe_listener_stream::NamedPipeServerStream::new(
|
||||
agent_state.cancellation_token.clone(),
|
||||
);
|
||||
|
||||
let cloned_agent_state = agent_state.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = ssh_agent::serve(
|
||||
stream,
|
||||
cloned_agent_state.clone(),
|
||||
cloned_agent_state.keystore.clone(),
|
||||
cloned_agent_state.cancellation_token.clone(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
Ok(agent_state)
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,15 @@ default = []
|
||||
manual_test = []
|
||||
|
||||
[dependencies]
|
||||
base64 = "=0.22.1"
|
||||
hex = "=0.4.3"
|
||||
anyhow = "=1.0.93"
|
||||
desktop_core = { path = "../core" }
|
||||
napi = { version = "=2.16.13", features = ["async"] }
|
||||
napi-derive = "=2.16.12"
|
||||
tokio = { version = "1.38.0" }
|
||||
tokio-util = "0.7.11"
|
||||
tokio = { version = "=1.40.0" }
|
||||
tokio-util = "=0.7.11"
|
||||
tokio-stream = "=0.1.15"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-registry = "=0.3.0"
|
||||
|
||||
35
apps/desktop/desktop_native/napi/index.d.ts
vendored
35
apps/desktop/desktop_native/napi/index.d.ts
vendored
@@ -42,6 +42,41 @@ export declare namespace clipboards {
|
||||
export function read(): Promise<string>
|
||||
export function write(text: string, password: boolean): Promise<void>
|
||||
}
|
||||
export declare namespace sshagent {
|
||||
export interface PrivateKey {
|
||||
privateKey: string
|
||||
name: string
|
||||
cipherId: string
|
||||
}
|
||||
export interface SshKey {
|
||||
privateKey: string
|
||||
publicKey: string
|
||||
keyFingerprint: string
|
||||
}
|
||||
export const enum SshKeyImportStatus {
|
||||
/** ssh key was parsed correctly and will be returned in the result */
|
||||
Success = 0,
|
||||
/** ssh key was parsed correctly but is encrypted and requires a password */
|
||||
PasswordRequired = 1,
|
||||
/** ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect */
|
||||
WrongPassword = 2,
|
||||
/** ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key */
|
||||
ParsingError = 3,
|
||||
/** ssh key type is not supported (e.g. ecdsa) */
|
||||
UnsupportedKeyType = 4
|
||||
}
|
||||
export interface SshKeyImportResult {
|
||||
status: SshKeyImportStatus
|
||||
sshKey?: SshKey
|
||||
}
|
||||
export function serve(callback: (err: Error | null, arg: string) => any): Promise<SshAgentState>
|
||||
export function stop(agentState: SshAgentState): void
|
||||
export function setKeys(agentState: SshAgentState, newKeys: Array<PrivateKey>): void
|
||||
export function lock(agentState: SshAgentState): void
|
||||
export function importKey(encodedKey: string, password: string): SshKeyImportResult
|
||||
export function generateKeypair(keyAlgorithm: string): Promise<SshKey>
|
||||
export class SshAgentState { }
|
||||
}
|
||||
export declare namespace processisolations {
|
||||
export function disableCoredumps(): Promise<void>
|
||||
export function isCoreDumpingDisabled(): Promise<boolean>
|
||||
|
||||
@@ -54,12 +54,16 @@ pub mod biometrics {
|
||||
hwnd: napi::bindgen_prelude::Buffer,
|
||||
message: String,
|
||||
) -> napi::Result<bool> {
|
||||
Biometric::prompt(hwnd.into(), message).await.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
Biometric::prompt(hwnd.into(), message)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn available() -> napi::Result<bool> {
|
||||
Biometric::available().await.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
Biometric::available()
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
@@ -151,6 +155,199 @@ pub mod clipboards {
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub mod sshagent {
|
||||
use std::sync::Arc;
|
||||
|
||||
use napi::{
|
||||
bindgen_prelude::Promise,
|
||||
threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction},
|
||||
};
|
||||
use tokio::{self, sync::Mutex};
|
||||
|
||||
#[napi]
|
||||
pub struct SshAgentState {
|
||||
state: desktop_core::ssh_agent::BitwardenDesktopAgent,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct PrivateKey {
|
||||
pub private_key: String,
|
||||
pub name: String,
|
||||
pub cipher_id: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct SshKey {
|
||||
pub private_key: String,
|
||||
pub public_key: String,
|
||||
pub key_fingerprint: String,
|
||||
}
|
||||
|
||||
impl From<desktop_core::ssh_agent::importer::SshKey> for SshKey {
|
||||
fn from(key: desktop_core::ssh_agent::importer::SshKey) -> Self {
|
||||
SshKey {
|
||||
private_key: key.private_key,
|
||||
public_key: key.public_key,
|
||||
key_fingerprint: key.key_fingerprint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub enum SshKeyImportStatus {
|
||||
/// ssh key was parsed correctly and will be returned in the result
|
||||
Success,
|
||||
/// ssh key was parsed correctly but is encrypted and requires a password
|
||||
PasswordRequired,
|
||||
/// ssh key was parsed correctly, and a password was provided when calling the import, but it was incorrect
|
||||
WrongPassword,
|
||||
/// ssh key could not be parsed, either due to an incorrect / unsupported format (pkcs#8) or key type (ecdsa), or because the input is not an ssh key
|
||||
ParsingError,
|
||||
/// ssh key type is not supported (e.g. ecdsa)
|
||||
UnsupportedKeyType,
|
||||
}
|
||||
|
||||
impl From<desktop_core::ssh_agent::importer::SshKeyImportStatus> for SshKeyImportStatus {
|
||||
fn from(status: desktop_core::ssh_agent::importer::SshKeyImportStatus) -> Self {
|
||||
match status {
|
||||
desktop_core::ssh_agent::importer::SshKeyImportStatus::Success => {
|
||||
SshKeyImportStatus::Success
|
||||
}
|
||||
desktop_core::ssh_agent::importer::SshKeyImportStatus::PasswordRequired => {
|
||||
SshKeyImportStatus::PasswordRequired
|
||||
}
|
||||
desktop_core::ssh_agent::importer::SshKeyImportStatus::WrongPassword => {
|
||||
SshKeyImportStatus::WrongPassword
|
||||
}
|
||||
desktop_core::ssh_agent::importer::SshKeyImportStatus::ParsingError => {
|
||||
SshKeyImportStatus::ParsingError
|
||||
}
|
||||
desktop_core::ssh_agent::importer::SshKeyImportStatus::UnsupportedKeyType => {
|
||||
SshKeyImportStatus::UnsupportedKeyType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct SshKeyImportResult {
|
||||
pub status: SshKeyImportStatus,
|
||||
pub ssh_key: Option<SshKey>,
|
||||
}
|
||||
|
||||
impl From<desktop_core::ssh_agent::importer::SshKeyImportResult> for SshKeyImportResult {
|
||||
fn from(result: desktop_core::ssh_agent::importer::SshKeyImportResult) -> Self {
|
||||
SshKeyImportResult {
|
||||
status: result.status.into(),
|
||||
ssh_key: result.ssh_key.map(|k| k.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn serve(
|
||||
callback: ThreadsafeFunction<String, CalleeHandled>,
|
||||
) -> napi::Result<SshAgentState> {
|
||||
let (auth_request_tx, mut auth_request_rx) = tokio::sync::mpsc::channel::<(u32, String)>(32);
|
||||
let (auth_response_tx, auth_response_rx) = tokio::sync::broadcast::channel::<(u32, bool)>(32);
|
||||
let auth_response_tx_arc = Arc::new(Mutex::new(auth_response_tx));
|
||||
tokio::spawn(async move {
|
||||
let _ = auth_response_rx;
|
||||
|
||||
while let Some((request_id, cipher_uuid)) = auth_request_rx.recv().await {
|
||||
let cloned_request_id = request_id.clone();
|
||||
let cloned_cipher_uuid = cipher_uuid.clone();
|
||||
let cloned_response_tx_arc = auth_response_tx_arc.clone();
|
||||
let cloned_callback = callback.clone();
|
||||
tokio::spawn(async move {
|
||||
let request_id = cloned_request_id;
|
||||
let cipher_uuid = cloned_cipher_uuid;
|
||||
let auth_response_tx_arc = cloned_response_tx_arc;
|
||||
let callback = cloned_callback;
|
||||
let promise_result: Result<Promise<bool>, napi::Error> =
|
||||
callback.call_async(Ok(cipher_uuid)).await;
|
||||
match promise_result {
|
||||
Ok(promise_result) => match promise_result.await {
|
||||
Ok(result) => {
|
||||
let _ = auth_response_tx_arc.lock().await.send((request_id, result))
|
||||
.expect("should be able to send auth response to agent");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("[SSH Agent Native Module] calling UI callback promise was rejected: {}", e);
|
||||
let _ = auth_response_tx_arc.lock().await.send((request_id, false))
|
||||
.expect("should be able to send auth response to agent");
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
println!("[SSH Agent Native Module] calling UI callback could not create promise: {}", e);
|
||||
let _ = auth_response_tx_arc.lock().await.send((request_id, false))
|
||||
.expect("should be able to send auth response to agent");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
match desktop_core::ssh_agent::BitwardenDesktopAgent::start_server(
|
||||
auth_request_tx,
|
||||
Arc::new(Mutex::new(auth_response_rx)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(state) => Ok(SshAgentState { state }),
|
||||
Err(e) => Err(napi::Error::from_reason(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn stop(agent_state: &mut SshAgentState) -> napi::Result<()> {
|
||||
let bitwarden_agent_state = &mut agent_state.state;
|
||||
bitwarden_agent_state.stop();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn set_keys(
|
||||
agent_state: &mut SshAgentState,
|
||||
new_keys: Vec<PrivateKey>,
|
||||
) -> napi::Result<()> {
|
||||
let bitwarden_agent_state = &mut agent_state.state;
|
||||
bitwarden_agent_state
|
||||
.set_keys(
|
||||
new_keys
|
||||
.iter()
|
||||
.map(|k| (k.private_key.clone(), k.name.clone(), k.cipher_id.clone()))
|
||||
.collect(),
|
||||
)
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn lock(agent_state: &mut SshAgentState) -> napi::Result<()> {
|
||||
let bitwarden_agent_state = &mut agent_state.state;
|
||||
bitwarden_agent_state
|
||||
.lock()
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub fn import_key(encoded_key: String, password: String) -> napi::Result<SshKeyImportResult> {
|
||||
let result = desktop_core::ssh_agent::importer::import_key(encoded_key, password)
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
|
||||
Ok(result.into())
|
||||
}
|
||||
|
||||
#[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]
|
||||
pub mod processisolations {
|
||||
#[napi]
|
||||
@@ -172,12 +369,19 @@ pub mod processisolations {
|
||||
|
||||
#[napi]
|
||||
pub mod powermonitors {
|
||||
use napi::{threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode}, tokio};
|
||||
use napi::{
|
||||
threadsafe_function::{
|
||||
ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode,
|
||||
},
|
||||
tokio,
|
||||
};
|
||||
|
||||
#[napi]
|
||||
pub async fn on_lock(callback: ThreadsafeFunction<(), CalleeHandled>) -> napi::Result<()> {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(32);
|
||||
desktop_core::powermonitor::on_lock(tx).await.map_err(|e| napi::Error::from_reason(e.to_string()))?;
|
||||
desktop_core::powermonitor::on_lock(tx)
|
||||
.await
|
||||
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
|
||||
tokio::spawn(async move {
|
||||
while let Some(message) = rx.recv().await {
|
||||
callback.call(Ok(message.into()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
@@ -190,7 +394,6 @@ pub mod powermonitors {
|
||||
pub async fn is_lock_monitor_available() -> napi::Result<bool> {
|
||||
Ok(desktop_core::powermonitor::is_lock_monitor_available().await)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#[napi]
|
||||
|
||||
@@ -419,6 +419,23 @@
|
||||
"enableHardwareAccelerationDesc" | i18n
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="form-group" *ngIf="showSshAgentOption">
|
||||
<div class="checkbox">
|
||||
<label for="enableSshAgent">
|
||||
<input
|
||||
id="enableSshAgent"
|
||||
type="checkbox"
|
||||
aria-describedby="enableSshAgentHelp"
|
||||
formControlName="enableSshAgent"
|
||||
(change)="saveSshAgent()"
|
||||
/>
|
||||
{{ "enableSshAgent" | i18n }}
|
||||
</label>
|
||||
</div>
|
||||
<small id="enableSshAgentHelp" class="help-block">{{
|
||||
"enableSshAgentDesc" | i18n
|
||||
}}</small>
|
||||
</div>
|
||||
<div class="form-group" *ngIf="showDuckDuckGoIntegrationOption">
|
||||
<div class="checkbox">
|
||||
<label for="enableDuckDuckGoBrowserIntegration">
|
||||
|
||||
@@ -12,7 +12,9 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
@@ -53,6 +55,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
showAlwaysShowDock = false;
|
||||
requireEnableTray = false;
|
||||
showDuckDuckGoIntegrationOption = false;
|
||||
showSshAgentOption = false;
|
||||
isWindows: boolean;
|
||||
isLinux: boolean;
|
||||
|
||||
@@ -107,6 +110,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
disabled: true,
|
||||
}),
|
||||
enableHardwareAcceleration: true,
|
||||
enableSshAgent: false,
|
||||
enableDuckDuckGoBrowserIntegration: false,
|
||||
theme: [null as ThemeType | null],
|
||||
locale: [null as string | null],
|
||||
@@ -137,6 +141,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
private pinService: PinServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private nativeMessagingManifestService: NativeMessagingManifestService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
const isMac = this.platformUtilsService.getDevice() === DeviceType.MacOsDesktop;
|
||||
|
||||
@@ -200,6 +205,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
if (activeAccount == null || activeAccount.id == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showSshAgentOption = await this.configService.getFeatureFlag(FeatureFlag.SSHAgent);
|
||||
this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword();
|
||||
|
||||
this.isWindows = this.platformUtilsService.getDevice() === DeviceType.WindowsDesktop;
|
||||
@@ -272,6 +279,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
enableHardwareAcceleration: await firstValueFrom(
|
||||
this.desktopSettingsService.hardwareAcceleration$,
|
||||
),
|
||||
enableSshAgent: await firstValueFrom(this.desktopSettingsService.sshAgentEnabled$),
|
||||
theme: await firstValueFrom(this.themeStateService.selectedTheme$),
|
||||
locale: await firstValueFrom(this.i18nService.userSetLocale$),
|
||||
};
|
||||
@@ -723,6 +731,11 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
async saveSshAgent() {
|
||||
this.logService.debug("Saving Ssh Agent settings", this.form.value.enableSshAgent);
|
||||
await this.desktopSettingsService.setSshAgentEnabled(this.form.value.enableSshAgent);
|
||||
}
|
||||
|
||||
private async generateVaultTimeoutOptions(): Promise<VaultTimeoutOption[]> {
|
||||
let vaultTimeoutOptions: VaultTimeoutOption[] = [
|
||||
{ name: this.i18nService.t("oneMinute"), value: 1 },
|
||||
|
||||
@@ -22,6 +22,7 @@ import { SsoComponent } from "../auth/sso.component";
|
||||
import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component";
|
||||
import { TwoFactorComponent } from "../auth/two-factor.component";
|
||||
import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component";
|
||||
import { SshAgentService } from "../platform/services/ssh-agent.service";
|
||||
import { PremiumComponent } from "../vault/app/accounts/premium.component";
|
||||
import { AddEditCustomFieldsComponent } from "../vault/app/vault/add-edit-custom-fields.component";
|
||||
import { AddEditComponent } from "../vault/app/vault/add-edit.component";
|
||||
@@ -100,6 +101,7 @@ import { SendComponent } from "./tools/send/send.component";
|
||||
ViewComponent,
|
||||
ViewCustomFieldsComponent,
|
||||
],
|
||||
providers: [SshAgentService],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService as KeyServiceAbstraction } from "@bitwarden/key-management";
|
||||
|
||||
import { I18nRendererService } from "../../platform/services/i18n.renderer.service";
|
||||
import { SshAgentService } from "../../platform/services/ssh-agent.service";
|
||||
import { NativeMessagingService } from "../../services/native-messaging.service";
|
||||
|
||||
@Injectable()
|
||||
@@ -41,11 +42,13 @@ export class InitService {
|
||||
private encryptService: EncryptService,
|
||||
private userAutoUnlockKeyService: UserAutoUnlockKeyService,
|
||||
private accountService: AccountService,
|
||||
private sshAgentService: SshAgentService,
|
||||
@Inject(DOCUMENT) private document: Document,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
return async () => {
|
||||
await this.sshAgentService.init();
|
||||
this.nativeMessagingService.init();
|
||||
await this.stateService.init({ runMigrations: false }); // Desktop will run them in main process
|
||||
|
||||
|
||||
@@ -1684,10 +1684,10 @@
|
||||
"message": "Die Kontolöschung ist dauerhaft. Sie kann nicht rückgängig gemacht werden."
|
||||
},
|
||||
"cannotDeleteAccount": {
|
||||
"message": "Cannot delete account"
|
||||
"message": "Konto kann nicht gelöscht werden"
|
||||
},
|
||||
"cannotDeleteAccountDesc": {
|
||||
"message": "This action cannot be completed because your account is owned by an organization. Contact your organization administrator for additional details."
|
||||
"message": "Diese Aktion kann nicht abgeschlossen werden, da dein Konto im Besitz einer Organisation ist. Kontaktiere deinen Organisationsadministrator für weitere Details."
|
||||
},
|
||||
"accountDeleted": {
|
||||
"message": "Konto gelöscht"
|
||||
@@ -2394,7 +2394,7 @@
|
||||
"message": "E-Mail generieren"
|
||||
},
|
||||
"generatorBoundariesHint": {
|
||||
"message": "Value must be between $MIN$ and $MAX$",
|
||||
"message": "Wert muss zwischen $MIN$ und $MAX$ liegen",
|
||||
"description": "Explains spin box minimum and maximum values to the user",
|
||||
"placeholders": {
|
||||
"min": {
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
"typeSecureNote": {
|
||||
"message": "Secure note"
|
||||
},
|
||||
"typeSshKey": {
|
||||
"message": "SSH key"
|
||||
},
|
||||
"folders": {
|
||||
"message": "Folders"
|
||||
},
|
||||
@@ -177,6 +180,48 @@
|
||||
"address": {
|
||||
"message": "Address"
|
||||
},
|
||||
"sshPrivateKey": {
|
||||
"message": "Private key"
|
||||
},
|
||||
"sshPublicKey": {
|
||||
"message": "Public key"
|
||||
},
|
||||
"sshFingerprint": {
|
||||
"message": "Fingerprint"
|
||||
},
|
||||
"sshKeyAlgorithm": {
|
||||
"message": "Key type"
|
||||
},
|
||||
"sshKeyAlgorithmED25519": {
|
||||
"message": "ED25519"
|
||||
},
|
||||
"sshKeyAlgorithmRSA2048": {
|
||||
"message": "RSA 2048-Bit"
|
||||
},
|
||||
"sshKeyAlgorithmRSA3072": {
|
||||
"message": "RSA 3072-Bit"
|
||||
},
|
||||
"sshKeyAlgorithmRSA4096": {
|
||||
"message": "RSA 4096-Bit"
|
||||
},
|
||||
"sshKeyGenerated": {
|
||||
"message": "A new SSH key was generated"
|
||||
},
|
||||
"sshAgentUnlockRequired": {
|
||||
"message": "Please unlock your vault to approve the SSH key request."
|
||||
},
|
||||
"sshAgentUnlockTimeout": {
|
||||
"message": "SSH key request timed out."
|
||||
},
|
||||
"enableSshAgent": {
|
||||
"message": "Enable SSH agent"
|
||||
},
|
||||
"enableSshAgentDesc": {
|
||||
"message": "Enable the SSH agent to sign SSH requests right from your Bitwarden vault."
|
||||
},
|
||||
"enableSshAgentHelp": {
|
||||
"message": "The SSH agent is a service targeted at developers that allows you to sign SSH requests directly from your Bitwarden vault."
|
||||
},
|
||||
"premiumRequired": {
|
||||
"message": "Premium required"
|
||||
},
|
||||
@@ -400,6 +445,12 @@
|
||||
"copyPassword": {
|
||||
"message": "Copy password"
|
||||
},
|
||||
"regenerateSshKey": {
|
||||
"message": "Regenerate SSH key"
|
||||
},
|
||||
"copySshPrivateKey": {
|
||||
"message": "Copy SSH private key"
|
||||
},
|
||||
"copyPassphrase": {
|
||||
"message": "Copy passphrase",
|
||||
"description": "Copy passphrase to clipboard"
|
||||
@@ -3225,6 +3276,36 @@
|
||||
"ssoError": {
|
||||
"message": "No free ports could be found for the sso login."
|
||||
},
|
||||
"authorize": {
|
||||
"message": "Authorize"
|
||||
},
|
||||
"deny": {
|
||||
"message": "Deny"
|
||||
},
|
||||
"sshkeyApprovalTitle": {
|
||||
"message": "Confirm SSH key usage"
|
||||
},
|
||||
"sshkeyApprovalMessageInfix": {
|
||||
"message": "is requesting access to"
|
||||
},
|
||||
"unknownApplication": {
|
||||
"message": "An application"
|
||||
},
|
||||
"sshKeyPasswordUnsupported": {
|
||||
"message": "Importing password protected SSH keys is not yet supported"
|
||||
},
|
||||
"invalidSshKey": {
|
||||
"message": "The SSH key is invalid"
|
||||
},
|
||||
"sshKeyTypeUnsupported": {
|
||||
"message": "The SSH key type is not supported"
|
||||
},
|
||||
"importSshKeyFromClipboard": {
|
||||
"message": "Import key from clipboard"
|
||||
},
|
||||
"sshKeyPasted": {
|
||||
"message": "SSH key imported successfully"
|
||||
},
|
||||
"fileSavedToDevice": {
|
||||
"message": "File saved to device. Manage from your device downloads."
|
||||
}
|
||||
|
||||
@@ -1800,7 +1800,7 @@
|
||||
"description": "Used as a card title description on the set password page to explain why the user is there"
|
||||
},
|
||||
"cardMetrics": {
|
||||
"message": "out of $TOTAL$",
|
||||
"message": "/$TOTAL$",
|
||||
"placeholders": {
|
||||
"total": {
|
||||
"content": "$1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as path from "path";
|
||||
|
||||
import { app } from "electron";
|
||||
import { app, ipcMain } from "electron";
|
||||
import { Subject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
|
||||
@@ -38,6 +38,7 @@ import { WindowMain } from "./main/window.main";
|
||||
import { ClipboardMain } from "./platform/main/clipboard.main";
|
||||
import { DesktopCredentialStorageListener } from "./platform/main/desktop-credential-storage-listener";
|
||||
import { MainCryptoFunctionService } from "./platform/main/main-crypto-function.service";
|
||||
import { MainSshAgentService } from "./platform/main/main-ssh-agent.service";
|
||||
import { DesktopSettingsService } from "./platform/services/desktop-settings.service";
|
||||
import { ElectronLogMainService } from "./platform/services/electron-log.main.service";
|
||||
import { ElectronStorageService } from "./platform/services/electron-storage.service";
|
||||
@@ -71,6 +72,7 @@ export class Main {
|
||||
nativeMessagingMain: NativeMessagingMain;
|
||||
clipboardMain: ClipboardMain;
|
||||
desktopAutofillSettingsService: DesktopAutofillSettingsService;
|
||||
sshAgentService: MainSshAgentService;
|
||||
|
||||
constructor() {
|
||||
// Set paths for portable builds
|
||||
@@ -240,6 +242,13 @@ 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();
|
||||
}
|
||||
});
|
||||
|
||||
new EphemeralValueStorageService();
|
||||
new SSOLocalhostCallbackService(this.environmentService, this.messagingService);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,19 @@ export class WindowMain {
|
||||
this.logService.info("Render process reloaded");
|
||||
});
|
||||
|
||||
ipcMain.on("window-focus", () => {
|
||||
if (this.win != null) {
|
||||
this.win.show();
|
||||
this.win.focus();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("window-hide", () => {
|
||||
if (this.win != null) {
|
||||
this.win.hide();
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
if (!isMacAppStore() && !isSnapStore()) {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<form [bitSubmit]="submit" [formGroup]="approveSshRequestForm">
|
||||
<bit-dialog>
|
||||
<div class="tw-font-semibold" bitDialogTitle>{{ "sshkeyApprovalTitle" | i18n }}</div>
|
||||
<div bitDialogContent>
|
||||
<b>{{params.applicationName}}</b> {{ "sshkeyApprovalMessageInfix" | i18n }}
|
||||
<b>{{params.cipherName}}</b>.
|
||||
</div>
|
||||
<div bitDialogFooter>
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary">
|
||||
<span>{{ "authorize" | i18n }}</span>
|
||||
</button>
|
||||
<button type="button" bitButton bitFormButton buttonType="secondary" bitDialogClose>
|
||||
{{ "deny" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
59
apps/desktop/src/platform/components/approve-ssh-request.ts
Normal file
59
apps/desktop/src/platform/components/approve-ssh-request.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Inject } from "@angular/core";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
} from "@bitwarden/components";
|
||||
import { DialogService } from "@bitwarden/components/src/dialog";
|
||||
import { CipherFormGeneratorComponent } from "@bitwarden/vault";
|
||||
|
||||
export interface ApproveSshRequestParams {
|
||||
cipherName: string;
|
||||
applicationName: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "app-approve-ssh-request",
|
||||
templateUrl: "approve-ssh-request.html",
|
||||
standalone: true,
|
||||
imports: [
|
||||
DialogModule,
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
CipherFormGeneratorComponent,
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
ReactiveFormsModule,
|
||||
AsyncActionsModule,
|
||||
FormFieldModule,
|
||||
],
|
||||
})
|
||||
export class ApproveSshRequestComponent {
|
||||
approveSshRequestForm = this.formBuilder.group({});
|
||||
|
||||
constructor(
|
||||
@Inject(DIALOG_DATA) protected params: ApproveSshRequestParams,
|
||||
private dialogRef: DialogRef<boolean>,
|
||||
private formBuilder: FormBuilder,
|
||||
) {}
|
||||
|
||||
static open(dialogService: DialogService, cipherName: string, applicationName: string) {
|
||||
return dialogService.open<boolean, ApproveSshRequestParams>(ApproveSshRequestComponent, {
|
||||
data: {
|
||||
cipherName,
|
||||
applicationName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
this.dialogRef.close(true);
|
||||
};
|
||||
}
|
||||
115
apps/desktop/src/platform/main/main-ssh-agent.service.ts
Normal file
115
apps/desktop/src/platform/main/main-ssh-agent.service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { concatMap, delay, filter, firstValueFrom, from, race, take, timer } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { sshagent } from "@bitwarden/desktop-napi";
|
||||
|
||||
class AgentResponse {
|
||||
requestId: number;
|
||||
accepted: boolean;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export class MainSshAgentService {
|
||||
SIGN_TIMEOUT = 60_000;
|
||||
REQUEST_POLL_INTERVAL = 50;
|
||||
|
||||
private requestResponses: AgentResponse[] = [];
|
||||
private request_id = 0;
|
||||
private agentState: sshagent.SshAgentState;
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private messagingService: MessagingService,
|
||||
) {}
|
||||
|
||||
init() {
|
||||
// handle sign request passing to UI
|
||||
sshagent
|
||||
.serve(async (err: Error, cipherId: string) => {
|
||||
// clear all old (> SIGN_TIMEOUT) requests
|
||||
this.requestResponses = this.requestResponses.filter(
|
||||
(response) => response.timestamp > new Date(Date.now() - this.SIGN_TIMEOUT),
|
||||
);
|
||||
|
||||
this.request_id += 1;
|
||||
const id_for_this_request = this.request_id;
|
||||
this.messagingService.send("sshagent.signrequest", {
|
||||
cipherId,
|
||||
requestId: id_for_this_request,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(
|
||||
race(
|
||||
from([false]).pipe(delay(this.SIGN_TIMEOUT)),
|
||||
|
||||
//poll for response
|
||||
timer(0, this.REQUEST_POLL_INTERVAL).pipe(
|
||||
concatMap(() => from(this.requestResponses)),
|
||||
filter((response) => response.requestId == id_for_this_request),
|
||||
take(1),
|
||||
concatMap(() => from([true])),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = this.requestResponses.find(
|
||||
(response) => response.requestId == id_for_this_request,
|
||||
);
|
||||
|
||||
this.requestResponses = this.requestResponses.filter(
|
||||
(response) => response.requestId != id_for_this_request,
|
||||
);
|
||||
|
||||
return response.accepted;
|
||||
})
|
||||
.then((agentState: sshagent.SshAgentState) => {
|
||||
this.agentState = agentState;
|
||||
this.logService.info("SSH agent started");
|
||||
})
|
||||
.catch((e) => {
|
||||
this.logService.error("SSH agent encountered an error: ", e);
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"sshagent.setkeys",
|
||||
async (event: any, keys: { name: string; privateKey: string; cipherId: string }[]) => {
|
||||
if (this.agentState != null) {
|
||||
sshagent.setKeys(this.agentState, keys);
|
||||
}
|
||||
},
|
||||
);
|
||||
ipcMain.handle(
|
||||
"sshagent.signrequestresponse",
|
||||
async (event: any, { requestId, accepted }: { requestId: number; accepted: boolean }) => {
|
||||
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) {
|
||||
sshagent.lock(this.agentState);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sshagent as ssh } from "desktop_native/napi";
|
||||
import { ipcRenderer } from "electron";
|
||||
|
||||
import { DeviceType } from "@bitwarden/common/enums";
|
||||
@@ -40,6 +41,30 @@ const clipboard = {
|
||||
write: (message: ClipboardWriteMessage) => ipcRenderer.invoke("clipboard.write", message),
|
||||
};
|
||||
|
||||
const sshAgent = {
|
||||
init: async () => {
|
||||
await ipcRenderer.invoke("sshagent.init");
|
||||
},
|
||||
setKeys: (keys: { name: string; privateKey: string; cipherId: string }[]): Promise<void> =>
|
||||
ipcRenderer.invoke("sshagent.setkeys", keys),
|
||||
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");
|
||||
},
|
||||
importKey: async (key: string, password: string): Promise<ssh.SshKeyImportResult> => {
|
||||
const res = await ipcRenderer.invoke("sshagent.importkey", {
|
||||
privateKey: key,
|
||||
password: password,
|
||||
});
|
||||
return res;
|
||||
},
|
||||
};
|
||||
|
||||
const powermonitor = {
|
||||
isLockMonitorAvailable: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke("powermonitor.isLockMonitorAvailable"),
|
||||
@@ -106,6 +131,8 @@ export default {
|
||||
isSnapStore: isSnapStore(),
|
||||
isAppImage: isAppImage(),
|
||||
reloadProcess: () => ipcRenderer.send("reload-process"),
|
||||
focusWindow: () => ipcRenderer.send("window-focus"),
|
||||
hideWindow: () => ipcRenderer.send("window-hide"),
|
||||
log: (level: LogLevelType, message?: any, ...optionalParams: any[]) =>
|
||||
ipcRenderer.invoke("ipc.log", { level, message, optionalParams }),
|
||||
|
||||
@@ -150,6 +177,7 @@ export default {
|
||||
storage,
|
||||
passwords,
|
||||
clipboard,
|
||||
sshAgent,
|
||||
powermonitor,
|
||||
nativeMessaging,
|
||||
crypto,
|
||||
|
||||
@@ -66,6 +66,10 @@ const BROWSER_INTEGRATION_FINGERPRINT_ENABLED = new KeyDefinition<boolean>(
|
||||
},
|
||||
);
|
||||
|
||||
const SSH_AGENT_ENABLED = new KeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "sshAgentEnabled", {
|
||||
deserializer: (b) => b,
|
||||
});
|
||||
|
||||
const MINIMIZE_ON_COPY = new UserKeyDefinition<boolean>(DESKTOP_SETTINGS_DISK, "minimizeOnCopy", {
|
||||
deserializer: (b) => b,
|
||||
clearOn: [], // User setting, no need to clear
|
||||
@@ -139,6 +143,10 @@ export class DesktopSettingsService {
|
||||
browserIntegrationFingerprintEnabled$ =
|
||||
this.browserIntegrationFingerprintEnabledState.state$.pipe(map(Boolean));
|
||||
|
||||
private readonly sshAgentEnabledState = this.stateProvider.getGlobal(SSH_AGENT_ENABLED);
|
||||
|
||||
sshAgentEnabled$ = this.sshAgentEnabledState.state$.pipe(map(Boolean));
|
||||
|
||||
private readonly minimizeOnCopyState = this.stateProvider.getActive(MINIMIZE_ON_COPY);
|
||||
|
||||
/**
|
||||
@@ -246,6 +254,13 @@ export class DesktopSettingsService {
|
||||
await this.browserIntegrationFingerprintEnabledState.update(() => value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a setting for whether or not the SSH agent is enabled.
|
||||
*/
|
||||
async setSshAgentEnabled(value: boolean) {
|
||||
await this.sshAgentEnabledState.update(() => value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the minimize on copy value for the current user.
|
||||
* @param value `true` if the application should minimize when a value is copied,
|
||||
|
||||
183
apps/desktop/src/platform/services/ssh-agent.service.ts
Normal file
183
apps/desktop/src/platform/services/ssh-agent.service.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { Injectable, OnDestroy } from "@angular/core";
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
concatMap,
|
||||
EMPTY,
|
||||
filter,
|
||||
from,
|
||||
map,
|
||||
of,
|
||||
Subject,
|
||||
switchMap,
|
||||
takeUntil,
|
||||
timeout,
|
||||
TimeoutError,
|
||||
timer,
|
||||
withLatestFrom,
|
||||
} from "rxjs";
|
||||
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { CommandDefinition, MessageListener } from "@bitwarden/common/platform/messaging";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { ApproveSshRequestComponent } from "../components/approve-ssh-request";
|
||||
|
||||
import { DesktopSettingsService } from "./desktop-settings.service";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class SshAgentService implements OnDestroy {
|
||||
SSH_REFRESH_INTERVAL = 1000;
|
||||
SSH_VAULT_UNLOCK_REQUEST_TIMEOUT = 1000 * 60;
|
||||
SSH_REQUEST_UNLOCK_POLLING_INTERVAL = 100;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
constructor(
|
||||
private cipherService: CipherService,
|
||||
private logService: LogService,
|
||||
private dialogService: DialogService,
|
||||
private messageListener: MessageListener,
|
||||
private authService: AuthService,
|
||||
private toastService: ToastService,
|
||||
private i18nService: I18nService,
|
||||
private desktopSettingsService: DesktopSettingsService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
const isSshAgentFeatureEnabled = await this.configService.getFeatureFlag(FeatureFlag.SSHAgent);
|
||||
if (isSshAgentFeatureEnabled) {
|
||||
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();
|
||||
this.toastService.showToast({
|
||||
variant: "info",
|
||||
title: null,
|
||||
message: this.i18nService.t("sshAgentUnlockRequired"),
|
||||
});
|
||||
return this.authService.activeAccountStatus$.pipe(
|
||||
filter((status) => status === AuthenticationStatus.Unlocked),
|
||||
timeout(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(([message, decryptedCiphers]) => {
|
||||
const cipherId = message.cipherId as string;
|
||||
const requestId = message.requestId as number;
|
||||
|
||||
if (decryptedCiphers === undefined) {
|
||||
return of(false).pipe(
|
||||
switchMap((result) =>
|
||||
ipc.platform.sshAgent.signRequestResponse(requestId, Boolean(result)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const cipher = decryptedCiphers.find((cipher) => cipher.id == cipherId);
|
||||
|
||||
ipc.platform.focusWindow();
|
||||
const dialogRef = ApproveSshRequestComponent.open(
|
||||
this.dialogService,
|
||||
cipher.name,
|
||||
this.i18nService.t("unknownApplication"),
|
||||
);
|
||||
|
||||
return dialogRef.closed.pipe(
|
||||
switchMap((result) => {
|
||||
return ipc.platform.sshAgent.signRequestResponse(requestId, Boolean(result));
|
||||
}),
|
||||
);
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
combineLatest([
|
||||
timer(0, this.SSH_REFRESH_INTERVAL),
|
||||
this.desktopSettingsService.sshAgentEnabled$,
|
||||
])
|
||||
.pipe(
|
||||
concatMap(async ([, enabled]) => {
|
||||
if (!enabled) {
|
||||
await ipc.platform.sshAgent.setKeys([]);
|
||||
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,
|
||||
);
|
||||
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() {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="box-content">
|
||||
<div class="box-content-row" *ngIf="!editMode" appBoxRow>
|
||||
<label for="type">{{ "type" | i18n }}</label>
|
||||
<select id="type" name="Type" [(ngModel)]="cipher.type">
|
||||
<select id="type" name="Type" [(ngModel)]="cipher.type" (change)="typeChange()">
|
||||
<option *ngFor="let o of typeOptions" [ngValue]="o.value">{{ o.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -471,6 +471,115 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ssh Key -->
|
||||
<div *ngIf="cipher.type === cipherType.SshKey">
|
||||
<div class="box-content-row box-content-row-flex" appBoxRow>
|
||||
<div class="row-main">
|
||||
<label for="sshPrivateKey">{{ "sshPrivateKey" | i18n }}</label>
|
||||
<div
|
||||
*ngIf="!showPrivateKey"
|
||||
class="monospaced"
|
||||
style="white-space: pre-line"
|
||||
[innerText]="cipher.sshKey.maskedPrivateKey"
|
||||
></div>
|
||||
<div
|
||||
*ngIf="showPrivateKey"
|
||||
class="monospaced"
|
||||
style="white-space: pre-line"
|
||||
[innerText]="cipher.sshKey.privateKey"
|
||||
></div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
appA11yTitle="{{ 'copySshPrivateKey' | i18n }}"
|
||||
(click)="copy(this.cipher.sshKey.privateKey, 'sshPrivateKey', 'SshPrivateKey')"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
|
||||
(click)="togglePrivateKey()"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-lg"
|
||||
aria-hidden="true"
|
||||
[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>
|
||||
<div class="row-main">
|
||||
<label for="sshPublicKey">{{ "sshPublicKey" | i18n }}</label>
|
||||
<input
|
||||
id="sshPublicKey"
|
||||
type="text"
|
||||
name="SSHKey.SSHPublicKey"
|
||||
[ngModel]="cipher.sshKey.publicKey"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
(click)="copy(cipher.sshKey.publicKey, 'sshPublicKey', 'SSHPublicKey')"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-flex" appBoxRow>
|
||||
<div class="row-main">
|
||||
<label for="sshKeyFingerprint">{{ "sshFingerprint" | i18n }}</label>
|
||||
<input
|
||||
id="sshKeyFingerprint"
|
||||
type="text"
|
||||
name="SSHKey.SSHKeyFingerprint"
|
||||
[ngModel]="cipher.sshKey.keyFingerprint"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
(click)="copy(cipher.sshKey.keyFingerprint, 'sshFingerprint', 'SSHFingerprint')"
|
||||
appA11yTitle="{{ 'generateSSHKey' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-content-row box-content-row-flex" appBoxRow>
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
(click)="importSshKeyFromClipboard()"
|
||||
>
|
||||
{{ "importSshKeyFromClipboard" | i18n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" *ngIf="cipher.type === cipherType.Login">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DatePipe } from "@angular/common";
|
||||
import { Component, NgZone, OnChanges, OnInit, OnDestroy, ViewChild } from "@angular/core";
|
||||
import { Component, NgZone, OnChanges, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
import { NgForm } from "@angular/forms";
|
||||
import { sshagent as sshAgent } from "desktop_native/napi";
|
||||
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component";
|
||||
@@ -18,8 +19,9 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
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 } from "@bitwarden/components";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||
|
||||
const BroadcasterSubscriptionId = "AddEditComponent";
|
||||
@@ -31,6 +33,7 @@ const BroadcasterSubscriptionId = "AddEditComponent";
|
||||
export class AddEditComponent extends BaseAddEditComponent implements OnInit, OnChanges, OnDestroy {
|
||||
@ViewChild("form")
|
||||
private form: NgForm;
|
||||
|
||||
constructor(
|
||||
cipherService: CipherService,
|
||||
folderService: FolderService,
|
||||
@@ -51,6 +54,7 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
dialogService: DialogService,
|
||||
datePipe: DatePipe,
|
||||
configService: ConfigService,
|
||||
private toastService: ToastService,
|
||||
cipherAuthorizationService: CipherAuthorizationService,
|
||||
) {
|
||||
super(
|
||||
@@ -140,4 +144,68 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On
|
||||
"https://bitwarden.com/help/managing-items/#protect-individual-items",
|
||||
);
|
||||
}
|
||||
|
||||
async generateSshKey() {
|
||||
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;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("sshKeyGenerated"),
|
||||
});
|
||||
}
|
||||
|
||||
async importSshKeyFromClipboard() {
|
||||
const key = await this.platformUtilsService.readFromClipboard();
|
||||
const parsedKey = await ipc.platform.sshAgent.importKey(key, "");
|
||||
if (parsedKey == null || parsedKey.status === sshAgent.SshKeyImportStatus.ParsingError) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("invalidSshKey"),
|
||||
});
|
||||
return;
|
||||
} else if (parsedKey.status === sshAgent.SshKeyImportStatus.UnsupportedKeyType) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("sshKeyTypeUnsupported"),
|
||||
});
|
||||
} else if (
|
||||
parsedKey.status === sshAgent.SshKeyImportStatus.PasswordRequired ||
|
||||
parsedKey.status === sshAgent.SshKeyImportStatus.WrongPassword
|
||||
) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: this.i18nService.t("sshKeyPasswordUnsupported"),
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
this.cipher.sshKey.privateKey = parsedKey.sshKey.privateKey;
|
||||
this.cipher.sshKey.publicKey = parsedKey.sshKey.publicKey;
|
||||
this.cipher.sshKey.keyFingerprint = parsedKey.sshKey.keyFingerprint;
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
title: "",
|
||||
message: this.i18nService.t("sshKeyPasted"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
togglePrivateKey() {
|
||||
this.showPrivateKey = !this.showPrivateKey;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,4 +79,19 @@
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
class="filter-option"
|
||||
[ngClass]="{ active: activeFilter.cipherType === cipherTypeEnum.SshKey }"
|
||||
>
|
||||
<span class="filter-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="filter-button"
|
||||
(click)="applyFilter(cipherTypeEnum.SshKey)"
|
||||
[attr.aria-pressed]="activeFilter.cipherType === cipherTypeEnum.SshKey"
|
||||
>
|
||||
<i class="bwi bwi-fw bwi-key" aria-hidden="true"></i> {{ "typeSshKey" | i18n }}
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -399,6 +399,105 @@
|
||||
<div *ngIf="cipher.identity.country">{{ cipher.identity.country }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ssh Key -->
|
||||
<div *ngIf="cipher.sshKey">
|
||||
<div class="box-content-row box-content-row-flex" *ngIf="cipher.sshKey.privateKey">
|
||||
<div class="row-main">
|
||||
<span class="row-label">{{ "sshPrivateKey" | i18n }}</span>
|
||||
<div
|
||||
*ngIf="!showPrivateKey"
|
||||
class="monospaced"
|
||||
style="white-space: pre-line"
|
||||
[innerText]="cipher.sshKey.maskedPrivateKey"
|
||||
></div>
|
||||
<div
|
||||
*ngIf="showPrivateKey"
|
||||
class="monospaced"
|
||||
style="white-space: pre-line"
|
||||
[innerText]="cipher.sshKey.privateKey"
|
||||
></div>
|
||||
</div>
|
||||
<div class="action-buttons" *ngIf="cipher.viewPassword">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
|
||||
[attr.aria-pressed]="showPrivateKey"
|
||||
(click)="togglePrivateKey()"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-lg"
|
||||
aria-hidden="true"
|
||||
[ngClass]="{ 'bwi-eye': !showPrivateKey, 'bwi-eye-slash': showPrivateKey }"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
appA11yTitle="{{ 'copySSHPrivateKey' | i18n }}"
|
||||
(click)="copy(cipher.sshKey.privateKey, 'sshPrivateKey', 'SshPrivateKey')"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="box-content-row box-content-row-flex"
|
||||
*ngIf="cipher.sshKey.publicKey"
|
||||
appBoxRow
|
||||
>
|
||||
<div class="row-main">
|
||||
<label for="sshPublicKey">{{ "sshPublicKey" | i18n }}</label>
|
||||
<input
|
||||
id="sshPublicKey"
|
||||
type="text"
|
||||
name="SshKey.SshPublicKey"
|
||||
[ngModel]="cipher.sshKey.publicKey"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
(click)="copy(cipher.sshKey.publicKey, 'sshPublicKey', 'SshPublicKey')"
|
||||
appA11yTitle="{{ 'generateSshKey' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="box-content-row box-content-row-flex"
|
||||
*ngIf="cipher.sshKey.keyFingerprint"
|
||||
appBoxRow
|
||||
>
|
||||
<div class="row-main">
|
||||
<label for="sshKeyFingerprint">{{ "sshFingerprint" | i18n }}</label>
|
||||
<input
|
||||
id="sshKeyFingerprint"
|
||||
type="text"
|
||||
name="SshKey.SshKeyFingerprint"
|
||||
[ngModel]="cipher.sshKey.keyFingerprint"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="row-btn"
|
||||
appStopClick
|
||||
(click)="copy(cipher.sshKey.keyFingerprint, 'sshFingerprint', 'SshFingerprint')"
|
||||
appA11yTitle="{{ 'generateSshKey' | i18n }}"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box" *ngIf="cipher.login && cipher.login.hasUris">
|
||||
|
||||
@@ -330,6 +330,20 @@ export class EventService {
|
||||
this.getShortId(ev.organizationUserId),
|
||||
);
|
||||
break;
|
||||
case EventType.OrganizationUser_Deleted:
|
||||
msg = this.i18nService.t("deletedUserId", this.formatOrgUserId(ev));
|
||||
humanReadableMsg = this.i18nService.t(
|
||||
"deletedUserId",
|
||||
this.getShortId(ev.organizationUserId),
|
||||
);
|
||||
break;
|
||||
case EventType.OrganizationUser_Left:
|
||||
msg = this.i18nService.t("userLeftOrganization", this.formatOrgUserId(ev));
|
||||
humanReadableMsg = this.i18nService.t(
|
||||
"userLeftOrganization",
|
||||
this.getShortId(ev.organizationUserId),
|
||||
);
|
||||
break;
|
||||
// Org
|
||||
case EventType.Organization_Updated:
|
||||
msg = humanReadableMsg = this.i18nService.t("editedOrgSettings");
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
type="button"
|
||||
buttonType="secondary"
|
||||
bitButton
|
||||
*ngIf="isCritialAppsFeatureEnabled"
|
||||
[disabled]="!selectedIds.size"
|
||||
[loading]="markingAsCritical"
|
||||
(click)="markAppsAsCritical()"
|
||||
@@ -68,7 +69,7 @@
|
||||
<bit-table [dataSource]="dataSource">
|
||||
<ng-container header>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th *ngIf="isCritialAppsFeatureEnabled"></th>
|
||||
<th bitSortable="name" bitCell>{{ "application" | i18n }}</th>
|
||||
<th bitSortable="atRiskPasswords" bitCell>{{ "atRiskPasswords" | i18n }}</th>
|
||||
<th bitSortable="totalPasswords" bitCell>{{ "totalPasswords" | i18n }}</th>
|
||||
@@ -78,7 +79,7 @@
|
||||
</ng-container>
|
||||
<ng-template body let-rows$>
|
||||
<tr bitRow *ngFor="let r of rows$ | async; trackBy: trackByFunction">
|
||||
<td>
|
||||
<td *ngIf="isCritialAppsFeatureEnabled">
|
||||
<input
|
||||
bitCheckbox
|
||||
type="checkbox"
|
||||
|
||||
@@ -7,6 +7,8 @@ import { debounceTime, firstValueFrom, map } from "rxjs";
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
@@ -41,6 +43,7 @@ export class AllApplicationsComponent implements OnInit {
|
||||
protected organization: Organization;
|
||||
noItemsIcon = Icons.Security;
|
||||
protected markingAsCritical = false;
|
||||
isCritialAppsFeatureEnabled = false;
|
||||
|
||||
// MOCK DATA
|
||||
protected mockData = applicationTableMockData;
|
||||
@@ -49,7 +52,7 @@ export class AllApplicationsComponent implements OnInit {
|
||||
protected mockTotalMembersCount = 0;
|
||||
protected mockTotalAppsCount = 0;
|
||||
|
||||
ngOnInit() {
|
||||
async ngOnInit() {
|
||||
this.activatedRoute.paramMap
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
@@ -60,6 +63,10 @@ export class AllApplicationsComponent implements OnInit {
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.isCritialAppsFeatureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.CriticalApps,
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -70,6 +77,7 @@ export class AllApplicationsComponent implements OnInit {
|
||||
protected activatedRoute: ActivatedRoute,
|
||||
protected toastService: ToastService,
|
||||
protected organizationService: OrganizationService,
|
||||
protected configService: ConfigService,
|
||||
) {
|
||||
this.dataSource.data = applicationTableMockData;
|
||||
this.searchControl.valueChanges
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<bit-tab label="{{ 'allApplicationsWithCount' | i18n: apps.length }}">
|
||||
<tools-all-applications></tools-all-applications>
|
||||
</bit-tab>
|
||||
<bit-tab>
|
||||
<bit-tab *ngIf="isCritialAppsFeatureEnabled">
|
||||
<ng-template bitTabLabel>
|
||||
<i class="bwi bwi-star"></i>
|
||||
{{ "criticalApplicationsWithCount" | i18n: criticalApps.length }}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } 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 { AsyncActionsModule, ButtonModule, TabsModule } from "@bitwarden/components";
|
||||
|
||||
import { HeaderModule } from "../../layouts/header/header.module";
|
||||
@@ -39,9 +41,10 @@ export enum RiskInsightsTabType {
|
||||
TabsModule,
|
||||
],
|
||||
})
|
||||
export class RiskInsightsComponent {
|
||||
export class RiskInsightsComponent implements OnInit {
|
||||
tabIndex: RiskInsightsTabType;
|
||||
dataLastUpdated = new Date();
|
||||
isCritialAppsFeatureEnabled = false;
|
||||
|
||||
apps: any[] = [];
|
||||
criticalApps: any[] = [];
|
||||
@@ -65,9 +68,16 @@ export class RiskInsightsComponent {
|
||||
});
|
||||
};
|
||||
|
||||
async ngOnInit() {
|
||||
this.isCritialAppsFeatureEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.CriticalApps,
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
route.queryParams.pipe(takeUntilDestroyed()).subscribe(({ tabIndex }) => {
|
||||
this.tabIndex = !isNaN(tabIndex) ? tabIndex : RiskInsightsTabType.AllApps;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { firstValueFrom, Observable, Subject } from "rxjs";
|
||||
import { map } from "rxjs/operators";
|
||||
|
||||
import { CollectionView } from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
@@ -17,6 +18,8 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import {
|
||||
@@ -231,6 +234,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private premiumUpgradeService: PremiumUpgradePromptService,
|
||||
private cipherAuthorizationService: CipherAuthorizationService,
|
||||
private apiService: ApiService,
|
||||
) {
|
||||
this.updateTitle();
|
||||
}
|
||||
@@ -278,7 +282,20 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
|
||||
if (this._originalFormMode === "add" || this._originalFormMode === "clone") {
|
||||
this.formConfig.mode = "edit";
|
||||
}
|
||||
this.formConfig.originalCipher = await this.cipherService.get(cipherView.id);
|
||||
|
||||
let cipher: Cipher;
|
||||
|
||||
// When the form config is used within the Admin Console, retrieve the cipher from the admin endpoint
|
||||
if (this.formConfig.isAdminConsole) {
|
||||
const cipherResponse = await this.apiService.getCipherAdmin(cipherView.id);
|
||||
const cipherData = new CipherData(cipherResponse);
|
||||
cipher = new Cipher(cipherData);
|
||||
} else {
|
||||
cipher = await this.cipherService.get(cipherView.id);
|
||||
}
|
||||
|
||||
// Store the updated cipher so any following edits use the most up to date cipher
|
||||
this.formConfig.originalCipher = cipher;
|
||||
this._cipherModified = true;
|
||||
await this.changeMode("view");
|
||||
}
|
||||
|
||||
@@ -16,13 +16,39 @@
|
||||
"all" | i18n
|
||||
}}</label>
|
||||
</th>
|
||||
<th bitCell [class]="showExtraColumn ? 'lg:tw-w-3/5' : 'tw-w-full'">{{ "name" | i18n }}</th>
|
||||
<!-- Organization vault -->
|
||||
<th
|
||||
*ngIf="showAdminActions"
|
||||
bitCell
|
||||
bitSortable="name"
|
||||
[fn]="sortByName"
|
||||
[class]="showExtraColumn ? 'lg:tw-w-3/5' : 'tw-w-full'"
|
||||
>
|
||||
{{ "name" | i18n }}
|
||||
</th>
|
||||
<!-- Individual vault -->
|
||||
<th
|
||||
*ngIf="!showAdminActions"
|
||||
bitCell
|
||||
[class]="showExtraColumn ? 'lg:tw-w-3/5' : 'tw-w-full'"
|
||||
>
|
||||
{{ "name" | i18n }}
|
||||
</th>
|
||||
<th bitCell *ngIf="showOwner" class="tw-hidden tw-w-2/5 lg:tw-table-cell">
|
||||
{{ "owner" | i18n }}
|
||||
</th>
|
||||
<th bitCell class="tw-w-2/5" *ngIf="showCollections">{{ "collections" | i18n }}</th>
|
||||
<th bitCell class="tw-w-2/5" *ngIf="showGroups">{{ "groups" | i18n }}</th>
|
||||
<th bitCell class="tw-w-2/5" *ngIf="showPermissionsColumn">
|
||||
<th bitCell bitSortable="groups" [fn]="sortByGroups" class="tw-w-2/5" *ngIf="showGroups">
|
||||
{{ "groups" | i18n }}
|
||||
</th>
|
||||
<th
|
||||
bitCell
|
||||
bitSortable="permissions"
|
||||
default="desc"
|
||||
[fn]="sortByPermissions"
|
||||
class="tw-w-2/5"
|
||||
*ngIf="showPermissionsColumn"
|
||||
>
|
||||
{{ "permission" | i18n }}
|
||||
</th>
|
||||
<th bitCell class="tw-w-12 tw-text-right">
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { SelectionModel } from "@angular/cdk/collections";
|
||||
import { Component, EventEmitter, Input, Output } from "@angular/core";
|
||||
|
||||
import { CollectionView, Unassigned } from "@bitwarden/admin-console/common";
|
||||
import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { TableDataSource } from "@bitwarden/components";
|
||||
import { SortDirection, TableDataSource } from "@bitwarden/components";
|
||||
|
||||
import { GroupView } from "../../../admin-console/organizations/core";
|
||||
|
||||
import {
|
||||
CollectionPermission,
|
||||
convertToPermission,
|
||||
} from "./../../../admin-console/organizations/shared/components/access-selector/access-selector.models";
|
||||
import { VaultItem } from "./vault-item";
|
||||
import { VaultItemEvent } from "./vault-item-event";
|
||||
|
||||
@@ -17,6 +21,8 @@ export const RowHeightClass = `tw-h-[65px]`;
|
||||
|
||||
const MaxSelectionCount = 500;
|
||||
|
||||
type ItemPermission = CollectionPermission | "NoAccess";
|
||||
|
||||
@Component({
|
||||
selector: "app-vault-items",
|
||||
templateUrl: "vault-items.component.html",
|
||||
@@ -333,6 +339,119 @@ export class VaultItemsComponent {
|
||||
return (canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && collectionNotSelected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name.
|
||||
*/
|
||||
protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => {
|
||||
// Collections before ciphers
|
||||
const collectionCompare = this.prioritizeCollections(a, b, direction);
|
||||
if (collectionCompare !== 0) {
|
||||
return collectionCompare;
|
||||
}
|
||||
|
||||
return this.compareNames(a, b);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sorts VaultItems based on group names
|
||||
*/
|
||||
protected sortByGroups = (a: VaultItem, b: VaultItem, direction: SortDirection) => {
|
||||
if (
|
||||
!(a.collection instanceof CollectionAdminView) &&
|
||||
!(b.collection instanceof CollectionAdminView)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const getFirstGroupName = (collection: CollectionAdminView): string => {
|
||||
if (collection.groups.length > 0) {
|
||||
return collection.groups.map((group) => this.getGroupName(group.id) || "").sort()[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Collections before ciphers
|
||||
const collectionCompare = this.prioritizeCollections(a, b, direction);
|
||||
if (collectionCompare !== 0) {
|
||||
return collectionCompare;
|
||||
}
|
||||
|
||||
const aGroupName = getFirstGroupName(a.collection as CollectionAdminView);
|
||||
const bGroupName = getFirstGroupName(b.collection as CollectionAdminView);
|
||||
|
||||
// Collections with groups come before collections without groups.
|
||||
// If a collection has no groups, getFirstGroupName returns null.
|
||||
if (aGroupName === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (bGroupName === null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return aGroupName.localeCompare(bGroupName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sorts VaultItems based on their permissions, with higher permissions taking precedence.
|
||||
* If permissions are equal, it falls back to sorting by name.
|
||||
*/
|
||||
protected sortByPermissions = (a: VaultItem, b: VaultItem, direction: SortDirection) => {
|
||||
const getPermissionPriority = (item: VaultItem): number => {
|
||||
const permission = item.collection
|
||||
? this.getCollectionPermission(item.collection)
|
||||
: this.getCipherPermission(item.cipher);
|
||||
|
||||
const priorityMap = {
|
||||
[CollectionPermission.Manage]: 5,
|
||||
[CollectionPermission.Edit]: 4,
|
||||
[CollectionPermission.EditExceptPass]: 3,
|
||||
[CollectionPermission.View]: 2,
|
||||
[CollectionPermission.ViewExceptPass]: 1,
|
||||
NoAccess: 0,
|
||||
};
|
||||
|
||||
return priorityMap[permission] ?? -1;
|
||||
};
|
||||
|
||||
// Collections before ciphers
|
||||
const collectionCompare = this.prioritizeCollections(a, b, direction);
|
||||
if (collectionCompare !== 0) {
|
||||
return collectionCompare;
|
||||
}
|
||||
|
||||
const priorityA = getPermissionPriority(a);
|
||||
const priorityB = getPermissionPriority(b);
|
||||
|
||||
// Higher priority first
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB;
|
||||
}
|
||||
|
||||
return this.compareNames(a, b);
|
||||
};
|
||||
|
||||
private compareNames(a: VaultItem, b: VaultItem): number {
|
||||
const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name;
|
||||
return getName(a).localeCompare(getName(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts VaultItems by prioritizing collections over ciphers.
|
||||
* Collections are always placed before ciphers, regardless of the sorting direction.
|
||||
*/
|
||||
private prioritizeCollections(a: VaultItem, b: VaultItem, direction: SortDirection): number {
|
||||
if (a.collection && !b.collection) {
|
||||
return direction === "asc" ? -1 : 1;
|
||||
}
|
||||
|
||||
if (!a.collection && b.collection) {
|
||||
return direction === "asc" ? 1 : -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private hasPersonalItems(): boolean {
|
||||
return this.selection.selected.some(({ cipher }) => cipher?.organizationId === null);
|
||||
}
|
||||
@@ -346,4 +465,58 @@ export class VaultItemsComponent {
|
||||
private getUniqueOrganizationIds(): Set<string> {
|
||||
return new Set(this.selection.selected.flatMap((i) => i.cipher?.organizationId ?? []));
|
||||
}
|
||||
|
||||
private getGroupName(groupId: string): string | undefined {
|
||||
return this.allGroups.find((g) => g.id === groupId)?.name;
|
||||
}
|
||||
|
||||
private getCollectionPermission(collection: CollectionView): ItemPermission {
|
||||
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
|
||||
|
||||
if (collection.id == Unassigned && organization?.canEditUnassignedCiphers) {
|
||||
return CollectionPermission.Edit;
|
||||
}
|
||||
|
||||
if (collection.assigned) {
|
||||
return convertToPermission(collection);
|
||||
}
|
||||
|
||||
return "NoAccess";
|
||||
}
|
||||
|
||||
private getCipherPermission(cipher: CipherView): ItemPermission {
|
||||
if (!cipher.organizationId || cipher.collectionIds.length === 0) {
|
||||
return CollectionPermission.Manage;
|
||||
}
|
||||
|
||||
const filteredCollections = this.allCollections?.filter((collection) => {
|
||||
if (collection.assigned) {
|
||||
return cipher.collectionIds.find((id) => {
|
||||
if (collection.id === id) {
|
||||
return collection;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (filteredCollections?.length === 1) {
|
||||
return convertToPermission(filteredCollections[0]);
|
||||
}
|
||||
|
||||
if (filteredCollections?.length > 0) {
|
||||
const permissions = filteredCollections.map((collection) => convertToPermission(collection));
|
||||
|
||||
const orderedPermissions = [
|
||||
CollectionPermission.Manage,
|
||||
CollectionPermission.Edit,
|
||||
CollectionPermission.EditExceptPass,
|
||||
CollectionPermission.View,
|
||||
CollectionPermission.ViewExceptPass,
|
||||
];
|
||||
|
||||
return orderedPermissions.find((perm) => permissions.includes(perm));
|
||||
}
|
||||
|
||||
return "NoAccess";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,6 +851,99 @@
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<!-- Ssh Key -->
|
||||
<ng-container *ngIf="cipher.type === cipherType.SshKey">
|
||||
<div class="row">
|
||||
<div class="col-12 form-group">
|
||||
<label for="sshKeyPrivateKey">{{ "sshKeyPrivateKey" | i18n }}</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="sshKeyPrivateKey"
|
||||
class="form-control"
|
||||
type="{{ showPrivateKey ? 'text' : 'password' }}"
|
||||
name="SSHKey.PrivateKey"
|
||||
[(ngModel)]="cipher.sshKey.privateKey"
|
||||
appInputVerbatim
|
||||
disabled
|
||||
readonly
|
||||
/>
|
||||
<div class="input-group-append" *ngIf="!cipher.isDeleted">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
|
||||
(click)="togglePrivateKey()"
|
||||
[disabled]="!cipher.viewPassword"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-lg"
|
||||
aria-hidden="true"
|
||||
[ngClass]="{ 'bwi-eye': !showPrivateKey, 'bwi-eye-slash': showPrivateKey }"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
appA11yTitle="{{ 'copySSHPrivateKey' | i18n }}"
|
||||
(click)="copy(cipher.sshKey.privateKey, 'sshKeyPrivateKey', 'PrivateKey')"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 form-group">
|
||||
<label for="sshKeyPublicKey">{{ "sshKeyPublicKey" | i18n }}</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="sshKeyPublicKey"
|
||||
class="form-control"
|
||||
type="text"
|
||||
name="SshKey.PublicKey"
|
||||
[(ngModel)]="cipher.sshKey.publicKey"
|
||||
appInputVerbatim
|
||||
disabled
|
||||
readonly
|
||||
/>
|
||||
<div class="input-group-append" *ngIf="!cipher.isDeleted">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
appA11yTitle="{{ 'copySshPublicKey' | i18n }}"
|
||||
(click)="copy(cipher.sshKey.publicKey, 'sshKeyPublicKey', 'PublicKey')"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 form-group">
|
||||
<label for="sshKeyFingerprint">{{ "sshKeyFingerprint" | i18n }}</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="sshKeyFingerprint"
|
||||
class="form-control"
|
||||
type="text"
|
||||
name="SshKey.Fingerprint"
|
||||
[(ngModel)]="cipher.sshKey.keyFingerprint"
|
||||
appInputVerbatim
|
||||
disabled
|
||||
readonly
|
||||
/>
|
||||
<div class="input-group-append" *ngIf="!cipher.isDeleted">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary"
|
||||
appA11yTitle="{{ 'copySshFingerprint' | i18n }}"
|
||||
(click)="copy(cipher.sshKey.keyFingerprint, 'sshKeyFingerprint', 'Fingerprint')"
|
||||
>
|
||||
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="form-group">
|
||||
<label for="notes">{{ "notes" | i18n }}</label>
|
||||
<textarea
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, Inject, OnDestroy, OnInit } from "@angular/core";
|
||||
import { combineLatest, map, Observable, Subject, takeUntil } from "rxjs";
|
||||
import { combineLatest, map, Observable, of, Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -53,6 +56,8 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
|
||||
private resetPasswordService: OrganizationUserResetPasswordService,
|
||||
private userVerificationService: UserVerificationService,
|
||||
private toastService: ToastService,
|
||||
private configService: ConfigService,
|
||||
private organizationService: OrganizationService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -60,23 +65,39 @@ export class OrganizationOptionsComponent implements OnInit, OnDestroy {
|
||||
map((policies) => policies.filter((policy) => policy.type === PolicyType.ResetPassword)),
|
||||
);
|
||||
|
||||
const managingOrg$ = this.configService
|
||||
.getFeatureFlag$(FeatureFlag.AccountDeprovisioning)
|
||||
.pipe(
|
||||
switchMap((isAccountDeprovisioningEnabled) =>
|
||||
isAccountDeprovisioningEnabled
|
||||
? this.organizationService.organizations$.pipe(
|
||||
map((organizations) =>
|
||||
organizations.find((o) => o.userIsManagedByOrganization === true),
|
||||
),
|
||||
)
|
||||
: of(null),
|
||||
),
|
||||
);
|
||||
|
||||
combineLatest([
|
||||
this.organization$,
|
||||
resetPasswordPolicies$,
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
managingOrg$,
|
||||
])
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(([organization, resetPasswordPolicies, decryptionOptions]) => {
|
||||
.subscribe(([organization, resetPasswordPolicies, decryptionOptions, managingOrg]) => {
|
||||
this.organization = organization;
|
||||
this.resetPasswordPolicy = resetPasswordPolicies.find(
|
||||
(p) => p.organizationId === organization.id,
|
||||
);
|
||||
|
||||
// A user can leave an organization if they are NOT using TDE and Key Connector, or they have a master password.
|
||||
// A user can leave an organization if they are NOT a managed user and they are NOT using TDE and Key Connector, or they have a master password.
|
||||
this.showLeaveOrgOption =
|
||||
(decryptionOptions.trustedDeviceOption == undefined &&
|
||||
managingOrg?.id !== organization.id &&
|
||||
((decryptionOptions.trustedDeviceOption == undefined &&
|
||||
decryptionOptions.keyConnectorOption == undefined) ||
|
||||
decryptionOptions.hasMasterPassword;
|
||||
decryptionOptions.hasMasterPassword);
|
||||
|
||||
// Hide the 3 dot menu if the user has no available actions
|
||||
this.hideMenu =
|
||||
|
||||
@@ -216,6 +216,12 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
|
||||
type: CipherType.SecureNote,
|
||||
icon: "bwi-sticky-note",
|
||||
},
|
||||
{
|
||||
id: "sshKey",
|
||||
name: this.i18nService.t("typeSshKey"),
|
||||
type: CipherType.SshKey,
|
||||
icon: "bwi-key",
|
||||
},
|
||||
];
|
||||
|
||||
const typeFilterSection: VaultFilterSection = {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { CollectionAdminService } from "@bitwarden/admin-console/common";
|
||||
import { CollectionAdminService, CollectionAdminView } from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
|
||||
import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service";
|
||||
|
||||
@@ -35,27 +34,41 @@ describe("AdminConsoleCipherFormConfigService", () => {
|
||||
status: OrganizationUserStatusType.Confirmed,
|
||||
};
|
||||
const policyAppliesToActiveUser$ = new BehaviorSubject<boolean>(true);
|
||||
const collection = {
|
||||
id: "12345-5555",
|
||||
organizationId: "234534-34334",
|
||||
name: "Test Collection 1",
|
||||
assigned: false,
|
||||
readOnly: true,
|
||||
} as CollectionAdminView;
|
||||
const collection2 = {
|
||||
id: "12345-6666",
|
||||
organizationId: "22222-2222",
|
||||
name: "Test Collection 2",
|
||||
assigned: true,
|
||||
readOnly: false,
|
||||
} as CollectionAdminView;
|
||||
|
||||
const organization$ = new BehaviorSubject<Organization>(testOrg as Organization);
|
||||
const organizations$ = new BehaviorSubject<Organization[]>([testOrg, testOrg2] as Organization[]);
|
||||
const getCipherAdmin = jest.fn().mockResolvedValue(null);
|
||||
const getCipher = jest.fn().mockResolvedValue(null);
|
||||
|
||||
beforeEach(async () => {
|
||||
getCipherAdmin.mockClear();
|
||||
getCipher.mockClear();
|
||||
getCipher.mockResolvedValue({ id: cipherId, name: "Test Cipher - (non-admin)" });
|
||||
getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" });
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
providers: [
|
||||
AdminConsoleCipherFormConfigService,
|
||||
{ provide: OrganizationService, useValue: { get$: () => organization$, organizations$ } },
|
||||
{
|
||||
provide: CollectionAdminService,
|
||||
useValue: { getAll: () => Promise.resolve([collection, collection2]) },
|
||||
},
|
||||
{
|
||||
provide: PolicyService,
|
||||
useValue: { policyAppliesToActiveUser$: () => policyAppliesToActiveUser$ },
|
||||
},
|
||||
{ provide: OrganizationService, useValue: { get$: () => organization$, organizations$ } },
|
||||
{ provide: CipherService, useValue: { get: getCipher } },
|
||||
{ provide: CollectionAdminService, useValue: { getAll: () => Promise.resolve([]) } },
|
||||
{
|
||||
provide: RoutedVaultFilterService,
|
||||
useValue: { filter$: new BehaviorSubject({ organizationId: testOrg.id }) },
|
||||
@@ -86,6 +99,12 @@ describe("AdminConsoleCipherFormConfigService", () => {
|
||||
expect(mode).toBe("edit");
|
||||
});
|
||||
|
||||
it("returns all collections", async () => {
|
||||
const { collections } = await adminConsoleConfigService.buildConfig("edit", cipherId);
|
||||
|
||||
expect(collections).toEqual([collection, collection2]);
|
||||
});
|
||||
|
||||
it("sets admin flag based on `canEditAllCiphers`", async () => {
|
||||
// Disable edit all ciphers on org
|
||||
testOrg.canEditAllCiphers = false;
|
||||
@@ -153,33 +172,14 @@ describe("AdminConsoleCipherFormConfigService", () => {
|
||||
expect(result.organizations).toEqual([testOrg, testOrg2]);
|
||||
});
|
||||
|
||||
describe("getCipher", () => {
|
||||
it("retrieves the cipher from the cipher service", async () => {
|
||||
testOrg.canEditAllCiphers = false;
|
||||
it("retrieves the cipher from the admin service", async () => {
|
||||
getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" });
|
||||
|
||||
adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService);
|
||||
adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService);
|
||||
|
||||
const result = await adminConsoleConfigService.buildConfig("clone", cipherId);
|
||||
await adminConsoleConfigService.buildConfig("add", cipherId);
|
||||
|
||||
expect(getCipher).toHaveBeenCalledWith(cipherId);
|
||||
expect(result.originalCipher.name).toBe("Test Cipher - (non-admin)");
|
||||
|
||||
// Admin service not needed when cipher service can return the cipher
|
||||
expect(getCipherAdmin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retrieves the cipher from the admin service", async () => {
|
||||
getCipher.mockResolvedValueOnce(null);
|
||||
getCipherAdmin.mockResolvedValue({ id: cipherId, name: "Test Cipher - (admin)" });
|
||||
|
||||
adminConsoleConfigService = TestBed.inject(AdminConsoleCipherFormConfigService);
|
||||
|
||||
await adminConsoleConfigService.buildConfig("add", cipherId);
|
||||
|
||||
expect(getCipherAdmin).toHaveBeenCalledWith(cipherId);
|
||||
|
||||
expect(getCipher).toHaveBeenCalledWith(cipherId);
|
||||
});
|
||||
expect(getCipherAdmin).toHaveBeenCalledWith(cipherId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType, OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { CipherId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
|
||||
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
|
||||
@@ -25,7 +23,6 @@ import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/se
|
||||
export class AdminConsoleCipherFormConfigService implements CipherFormConfigService {
|
||||
private policyService: PolicyService = inject(PolicyService);
|
||||
private organizationService: OrganizationService = inject(OrganizationService);
|
||||
private cipherService: CipherService = inject(CipherService);
|
||||
private routedVaultFilterService: RoutedVaultFilterService = inject(RoutedVaultFilterService);
|
||||
private collectionAdminService: CollectionAdminService = inject(CollectionAdminService);
|
||||
private apiService: ApiService = inject(ApiService);
|
||||
@@ -51,20 +48,8 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ
|
||||
map(([orgs, orgId]) => orgs.find((o) => o.id === orgId)),
|
||||
);
|
||||
|
||||
private editableCollections$ = this.organization$.pipe(
|
||||
switchMap(async (org) => {
|
||||
if (!org) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const collections = await this.collectionAdminService.getAll(org.id);
|
||||
// Users that can edit all ciphers can implicitly add to / edit within any collection
|
||||
if (org.canEditAllCiphers) {
|
||||
return collections;
|
||||
}
|
||||
// The user is only allowed to add/edit items to assigned collections that are not readonly
|
||||
return collections.filter((c) => c.assigned && !c.readOnly);
|
||||
}),
|
||||
private allCollections$ = this.organization$.pipe(
|
||||
switchMap(async (org) => await this.collectionAdminService.getAll(org.id)),
|
||||
);
|
||||
|
||||
async buildConfig(
|
||||
@@ -72,21 +57,17 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ
|
||||
cipherId?: CipherId,
|
||||
cipherType?: CipherType,
|
||||
): Promise<CipherFormConfig> {
|
||||
const cipher = await this.getCipher(cipherId);
|
||||
const [organization, allowPersonalOwnership, allOrganizations, allCollections] =
|
||||
await firstValueFrom(
|
||||
combineLatest([
|
||||
this.organization$,
|
||||
this.allowPersonalOwnership$,
|
||||
this.allOrganizations$,
|
||||
this.editableCollections$,
|
||||
this.allCollections$,
|
||||
]),
|
||||
);
|
||||
|
||||
const cipher = await this.getCipher(organization, cipherId);
|
||||
|
||||
const collections = allCollections.filter(
|
||||
(c) => c.organizationId === organization.id && c.assigned && !c.readOnly,
|
||||
);
|
||||
// When cloning from within the Admin Console, all organizations should be available.
|
||||
// Otherwise only the one in context should be
|
||||
const organizations = mode === "clone" ? allOrganizations : [organization];
|
||||
@@ -100,7 +81,7 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ
|
||||
admin: organization.canEditAllCiphers ?? false,
|
||||
allowPersonalOwnership: allowPersonalOwnershipOnlyForClone,
|
||||
originalCipher: cipher,
|
||||
collections,
|
||||
collections: allCollections,
|
||||
organizations,
|
||||
folders: [], // folders not applicable in the admin console
|
||||
hideIndividualVaultFields: true,
|
||||
@@ -108,19 +89,11 @@ export class AdminConsoleCipherFormConfigService implements CipherFormConfigServ
|
||||
};
|
||||
}
|
||||
|
||||
private async getCipher(organization: Organization, id?: CipherId): Promise<Cipher | null> {
|
||||
private async getCipher(id?: CipherId): Promise<Cipher | null> {
|
||||
if (id == null) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
// Check to see if the user has direct access to the cipher
|
||||
const cipherFromCipherService = await this.cipherService.get(id);
|
||||
|
||||
// If the organization doesn't allow admin/owners to edit all ciphers return the cipher
|
||||
if (!organization.canEditAllCiphers && cipherFromCipherService != null) {
|
||||
return cipherFromCipherService;
|
||||
}
|
||||
|
||||
// Retrieve the cipher through the means of an admin
|
||||
const cipherResponse = await this.apiService.getCipherAdmin(id);
|
||||
cipherResponse.edit = true;
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"criticalApplications": {
|
||||
"message": "Critical applications"
|
||||
},
|
||||
"accessIntelligence": {
|
||||
"message": "Access Intelligence"
|
||||
"riskInsights": {
|
||||
"message": "Risk Insights"
|
||||
},
|
||||
"passwordRisk": {
|
||||
"message": "Password Risk"
|
||||
@@ -3218,9 +3218,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"inviteSingleEmailDesc": {
|
||||
"message": "You have 1 invite remaining."
|
||||
},
|
||||
"userUsingTwoStep": {
|
||||
"message": "This user is using two-step login to protect their account."
|
||||
},
|
||||
@@ -9557,5 +9554,31 @@
|
||||
},
|
||||
"single-org-revoked-user-warning": {
|
||||
"message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations."
|
||||
},
|
||||
"deleteOrganizationUser": {
|
||||
"message": "Delete $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
},
|
||||
"description": "Title for the delete organization user dialog"
|
||||
}
|
||||
},
|
||||
"deleteOrganizationUserWarning": {
|
||||
"message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.",
|
||||
"description": "Warning for the delete organization user dialog"
|
||||
},
|
||||
"organizationUserDeleted": {
|
||||
"message": "Deleted $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"organizationUserDeletedDesc": {
|
||||
"message": "The user was removed from the organization and all associated user data has been deleted."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"criticalApplications": {
|
||||
"message": "Critical applications"
|
||||
},
|
||||
"accessIntelligence": {
|
||||
"message": "Access Intelligence"
|
||||
"riskInsights": {
|
||||
"message": "Risk Insights"
|
||||
},
|
||||
"passwordRisk": {
|
||||
"message": "Password Risk"
|
||||
@@ -3218,9 +3218,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"inviteSingleEmailDesc": {
|
||||
"message": "You have 1 invite remaining."
|
||||
},
|
||||
"userUsingTwoStep": {
|
||||
"message": "This user is using two-step login to protect their account."
|
||||
},
|
||||
@@ -9557,5 +9554,31 @@
|
||||
},
|
||||
"single-org-revoked-user-warning": {
|
||||
"message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations."
|
||||
},
|
||||
"deleteOrganizationUser": {
|
||||
"message": "Delete $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
},
|
||||
"description": "Title for the delete organization user dialog"
|
||||
}
|
||||
},
|
||||
"deleteOrganizationUserWarning": {
|
||||
"message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.",
|
||||
"description": "Warning for the delete organization user dialog"
|
||||
},
|
||||
"organizationUserDeleted": {
|
||||
"message": "Deleted $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"organizationUserDeletedDesc": {
|
||||
"message": "The user was removed from the organization and all associated user data has been deleted."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
"criticalApplications": {
|
||||
"message": "Kritik tətbiqlər"
|
||||
},
|
||||
"accessIntelligence": {
|
||||
"message": "Müraciət Kəşfiyyatı"
|
||||
"riskInsights": {
|
||||
"message": "Risk Insights"
|
||||
},
|
||||
"passwordRisk": {
|
||||
"message": "Parol riski"
|
||||
},
|
||||
"discoverAtRiskPasswords": {
|
||||
"message": "Riskli parolları kəşf edin və bu parolları dəyişdirməsi üçün istifadəçiləri məlumatlandırın."
|
||||
"message": "Discover at-risk passwords and notify users to change those passwords."
|
||||
},
|
||||
"dataLastUpdated": {
|
||||
"message": "Datanın son güncəlləmə tarixi: $DATE$",
|
||||
@@ -3218,9 +3218,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"inviteSingleEmailDesc": {
|
||||
"message": "1 dəvət haqqınız var."
|
||||
},
|
||||
"userUsingTwoStep": {
|
||||
"message": "Bu istifadəçinin hesabını qorumaq üçün iki addımlı giriş istifadə edilir."
|
||||
},
|
||||
@@ -9557,5 +9554,31 @@
|
||||
},
|
||||
"single-org-revoked-user-warning": {
|
||||
"message": "Uyumlu olmayan üzvlər rədd ediləcək. Digər bütün təşkilatları tərk etdikdən sonra üzvlər, administratorlar tərəfindən bərpa edilə bilər."
|
||||
},
|
||||
"deleteOrganizationUser": {
|
||||
"message": "$NAME$ - sil",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
},
|
||||
"description": "Title for the delete organization user dialog"
|
||||
}
|
||||
},
|
||||
"deleteOrganizationUserWarning": {
|
||||
"message": "Bir hesab silindikdə, Bitwarden hesabı və onun fərdi seyf dataları həmişəlik silinir. Təşkilatdakı kolleksiya dataları qalır. Bunları yenidən fəallaşdırmaq üçün bir hesab yaradılmalı və yenidən təşkilata qoşulması lazımdır.",
|
||||
"description": "Warning for the delete organization user dialog"
|
||||
},
|
||||
"organizationUserDeleted": {
|
||||
"message": "$NAME$ silindi",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"organizationUserDeletedDesc": {
|
||||
"message": "İstifadəçi təşkilatdan çıxarıldı və əlaqələndirilmiş bütün istifadəçi dataları silindi."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"criticalApplications": {
|
||||
"message": "Critical applications"
|
||||
},
|
||||
"accessIntelligence": {
|
||||
"message": "Access Intelligence"
|
||||
"riskInsights": {
|
||||
"message": "Risk Insights"
|
||||
},
|
||||
"passwordRisk": {
|
||||
"message": "Password Risk"
|
||||
@@ -3218,9 +3218,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"inviteSingleEmailDesc": {
|
||||
"message": "You have 1 invite remaining."
|
||||
},
|
||||
"userUsingTwoStep": {
|
||||
"message": "Гэты карыстальнік выкарыстоўвае двухэтапны ўваход для абароны свайго ўліковага запісу."
|
||||
},
|
||||
@@ -9557,5 +9554,31 @@
|
||||
},
|
||||
"single-org-revoked-user-warning": {
|
||||
"message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations."
|
||||
},
|
||||
"deleteOrganizationUser": {
|
||||
"message": "Delete $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
},
|
||||
"description": "Title for the delete organization user dialog"
|
||||
}
|
||||
},
|
||||
"deleteOrganizationUserWarning": {
|
||||
"message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.",
|
||||
"description": "Warning for the delete organization user dialog"
|
||||
},
|
||||
"organizationUserDeleted": {
|
||||
"message": "Deleted $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"organizationUserDeletedDesc": {
|
||||
"message": "The user was removed from the organization and all associated user data has been deleted."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
"criticalApplications": {
|
||||
"message": "Важни приложения"
|
||||
},
|
||||
"accessIntelligence": {
|
||||
"message": "Access Intelligence"
|
||||
"riskInsights": {
|
||||
"message": "Risk Insights"
|
||||
},
|
||||
"passwordRisk": {
|
||||
"message": "Рискова парола"
|
||||
},
|
||||
"discoverAtRiskPasswords": {
|
||||
"message": "Откриване на пароли в риск и известяване на потребителите да ги сменят."
|
||||
"message": "Discover at-risk passwords and notify users to change those passwords."
|
||||
},
|
||||
"dataLastUpdated": {
|
||||
"message": "Последно обновяване на данните: $DATE$",
|
||||
@@ -3218,9 +3218,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"inviteSingleEmailDesc": {
|
||||
"message": "Имате 1 оставаща покана."
|
||||
},
|
||||
"userUsingTwoStep": {
|
||||
"message": "Този потребител използва двустепенна защита за достъп."
|
||||
},
|
||||
@@ -9557,5 +9554,31 @@
|
||||
},
|
||||
"single-org-revoked-user-warning": {
|
||||
"message": "На членовете, които не отговарят на това условие, ще бъдат отнети правомощията. Администраторите могат да възстановяват правомощията на членовете, след като те напуснат всички останали организации."
|
||||
},
|
||||
"deleteOrganizationUser": {
|
||||
"message": "Изтриване на $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
},
|
||||
"description": "Title for the delete organization user dialog"
|
||||
}
|
||||
},
|
||||
"deleteOrganizationUserWarning": {
|
||||
"message": "Когато даден член бъде изтрит, неговата регистрация в Битуорден, както и данните от трезора му, ще бъдат изтрити завинаги. Данните за колекции ще останат в организацията. Ако искате да го върнете, той трябва да си създаде нова регистрация и да бъде включен отново.",
|
||||
"description": "Warning for the delete organization user dialog"
|
||||
},
|
||||
"organizationUserDeleted": {
|
||||
"message": "Изтрито: $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"organizationUserDeletedDesc": {
|
||||
"message": "Потребителят беше премахнат от организацията и всичките му данни бяха изтрити."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"criticalApplications": {
|
||||
"message": "Critical applications"
|
||||
},
|
||||
"accessIntelligence": {
|
||||
"message": "Access Intelligence"
|
||||
"riskInsights": {
|
||||
"message": "Risk Insights"
|
||||
},
|
||||
"passwordRisk": {
|
||||
"message": "Password Risk"
|
||||
@@ -3218,9 +3218,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"inviteSingleEmailDesc": {
|
||||
"message": "You have 1 invite remaining."
|
||||
},
|
||||
"userUsingTwoStep": {
|
||||
"message": "This user is using two-step login to protect their account."
|
||||
},
|
||||
@@ -9557,5 +9554,31 @@
|
||||
},
|
||||
"single-org-revoked-user-warning": {
|
||||
"message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations."
|
||||
},
|
||||
"deleteOrganizationUser": {
|
||||
"message": "Delete $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
},
|
||||
"description": "Title for the delete organization user dialog"
|
||||
}
|
||||
},
|
||||
"deleteOrganizationUserWarning": {
|
||||
"message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.",
|
||||
"description": "Warning for the delete organization user dialog"
|
||||
},
|
||||
"organizationUserDeleted": {
|
||||
"message": "Deleted $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"organizationUserDeletedDesc": {
|
||||
"message": "The user was removed from the organization and all associated user data has been deleted."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"criticalApplications": {
|
||||
"message": "Critical applications"
|
||||
},
|
||||
"accessIntelligence": {
|
||||
"message": "Access Intelligence"
|
||||
"riskInsights": {
|
||||
"message": "Risk Insights"
|
||||
},
|
||||
"passwordRisk": {
|
||||
"message": "Password Risk"
|
||||
@@ -3218,9 +3218,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"inviteSingleEmailDesc": {
|
||||
"message": "You have 1 invite remaining."
|
||||
},
|
||||
"userUsingTwoStep": {
|
||||
"message": "This user is using two-step login to protect their account."
|
||||
},
|
||||
@@ -9557,5 +9554,31 @@
|
||||
},
|
||||
"single-org-revoked-user-warning": {
|
||||
"message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations."
|
||||
},
|
||||
"deleteOrganizationUser": {
|
||||
"message": "Delete $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
},
|
||||
"description": "Title for the delete organization user dialog"
|
||||
}
|
||||
},
|
||||
"deleteOrganizationUserWarning": {
|
||||
"message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.",
|
||||
"description": "Warning for the delete organization user dialog"
|
||||
},
|
||||
"organizationUserDeleted": {
|
||||
"message": "Deleted $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"organizationUserDeletedDesc": {
|
||||
"message": "The user was removed from the organization and all associated user data has been deleted."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"criticalApplications": {
|
||||
"message": "Critical applications"
|
||||
},
|
||||
"accessIntelligence": {
|
||||
"message": "Access Intelligence"
|
||||
"riskInsights": {
|
||||
"message": "Risk Insights"
|
||||
},
|
||||
"passwordRisk": {
|
||||
"message": "Password Risk"
|
||||
@@ -3218,9 +3218,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"inviteSingleEmailDesc": {
|
||||
"message": "You have 1 invite remaining."
|
||||
},
|
||||
"userUsingTwoStep": {
|
||||
"message": "Aquest usuari fa servir l'inici de sessió en dues passes per protegir el seu compte."
|
||||
},
|
||||
@@ -9557,5 +9554,31 @@
|
||||
},
|
||||
"single-org-revoked-user-warning": {
|
||||
"message": "Non-compliant members will be revoked. Administrators can restore members once they leave all other organizations."
|
||||
},
|
||||
"deleteOrganizationUser": {
|
||||
"message": "Delete $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
},
|
||||
"description": "Title for the delete organization user dialog"
|
||||
}
|
||||
},
|
||||
"deleteOrganizationUserWarning": {
|
||||
"message": "When a member is deleted, their Bitwarden account and individual vault data will be permanently deleted. Collection data will remain in the organization. To reinstate them they must create an account and be onboarded again.",
|
||||
"description": "Warning for the delete organization user dialog"
|
||||
},
|
||||
"organizationUserDeleted": {
|
||||
"message": "Deleted $NAME$",
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"content": "$1",
|
||||
"example": "John Doe"
|
||||
}
|
||||
}
|
||||
},
|
||||
"organizationUserDeletedDesc": {
|
||||
"message": "The user was removed from the organization and all associated user data has been deleted."
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user