mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-14 10:26:19 +01:00
Merge branch 'main' into restrict-imports
This commit is contained in:
commit
72eba79799
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"]
|
||||
|
36
.github/workflows/build-browser.yml
vendored
36
.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,16 +34,24 @@ 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
|
||||
needs:
|
||||
- check-run
|
||||
outputs:
|
||||
repo_url: ${{ steps.gen_vars.outputs.repo_url }}
|
||||
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 +80,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 +120,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
|
||||
@ -229,8 +242,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
|
||||
@ -336,14 +351,16 @@ jobs:
|
||||
|
||||
crowdin-push:
|
||||
name: Crowdin Push
|
||||
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
|
||||
- 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 +398,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-browser')
|
||||
&& contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
- name: Login to Azure - Prod Subscription
|
||||
|
31
.github/workflows/build-cli.yml
vendored
31
.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,15 +35,23 @@ 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
|
||||
needs:
|
||||
- check-run
|
||||
outputs:
|
||||
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 +67,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 +90,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 +170,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 +322,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: |
|
||||
@ -389,7 +403,10 @@ jobs:
|
||||
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-cli')
|
||||
&& contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
- name: Login to Azure - Prod Subscription
|
||||
|
68
.github/workflows/build-desktop.yml
vendored
68
.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,20 @@ 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
|
||||
needs:
|
||||
- check-run
|
||||
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: |
|
||||
@ -54,6 +63,8 @@ jobs:
|
||||
setup:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- check-run
|
||||
outputs:
|
||||
package_version: ${{ steps.retrieve-version.outputs.package_version }}
|
||||
release_channel: ${{ steps.release-channel.outputs.channel }}
|
||||
@ -65,8 +76,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 +151,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 +253,8 @@ jobs:
|
||||
windows:
|
||||
name: Windows Build
|
||||
runs-on: windows-2022
|
||||
needs: setup
|
||||
needs:
|
||||
- setup
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
@ -248,8 +264,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 +465,8 @@ jobs:
|
||||
macos-build:
|
||||
name: MacOS Build
|
||||
runs-on: macos-13
|
||||
needs: setup
|
||||
needs:
|
||||
- setup
|
||||
env:
|
||||
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
@ -456,8 +475,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 +643,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 +864,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
|
||||
@ -1033,9 +1058,8 @@ jobs:
|
||||
- name: Deploy to TestFlight
|
||||
id: testflight-deploy
|
||||
if: |
|
||||
(github.ref == 'refs/heads/main'
|
||||
|| github.ref == 'refs/heads/rc'
|
||||
|| github.ref == 'refs/heads/hotfix-rc-desktop')
|
||||
github.event_name != 'pull_request_target'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop')
|
||||
env:
|
||||
APP_STORE_CONNECT_TEAM_ISSUER: ${{ secrets.APP_STORE_CONNECT_TEAM_ISSUER }}
|
||||
APP_STORE_CONNECT_AUTH_KEY: 6TV9MKN3GP
|
||||
@ -1050,9 +1074,8 @@ jobs:
|
||||
- name: Post message to a Slack channel
|
||||
id: slack-message
|
||||
if: |
|
||||
(github.ref == 'refs/heads/main'
|
||||
|| github.ref == 'refs/heads/rc'
|
||||
|| github.ref == 'refs/heads/hotfix-rc-desktop')
|
||||
github.event_name != 'pull_request_target'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc-desktop')
|
||||
uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0
|
||||
with:
|
||||
channel-id: C074F5UESQ0
|
||||
@ -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-desktop')
|
||||
&& contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
|
||||
- name: Login to Azure - Prod Subscription
|
||||
|
43
.github/workflows/build-web.yml
vendored
43
.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,15 +37,23 @@ 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
|
||||
needs:
|
||||
- check-run
|
||||
outputs:
|
||||
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
|
||||
@ -60,7 +69,8 @@ jobs:
|
||||
build-artifacts:
|
||||
name: Build artifacts
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
needs:
|
||||
- setup
|
||||
env:
|
||||
_VERSION: ${{ needs.setup.outputs.version }}
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
@ -89,8 +99,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
|
||||
@ -155,8 +167,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:
|
||||
@ -249,12 +263,15 @@ jobs:
|
||||
|
||||
crowdin-push:
|
||||
name: Crowdin Push
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: build-artifacts
|
||||
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- build-artifacts
|
||||
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 +299,10 @@ 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
|
||||
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-web')
|
||||
&& 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() {
|
||||
|
@ -436,9 +436,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
||||
|
||||
didAutofill = true;
|
||||
if (!options.skipLastUsed) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.cipherService.updateLastUsedDate(options.cipher.id);
|
||||
await this.cipherService.updateLastUsedDate(options.cipher.id);
|
||||
}
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
|
@ -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() {}
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { booleanAttribute, Component, Input, OnInit } from "@angular/core";
|
||||
import { Router, RouterModule } from "@angular/router";
|
||||
import { firstValueFrom, map, Observable } from "rxjs";
|
||||
import { BehaviorSubject, firstValueFrom, map, switchMap } from "rxjs";
|
||||
import { filter } from "rxjs/operators";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
@ -30,10 +31,18 @@ import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
|
||||
imports: [ItemModule, IconButtonModule, MenuModule, CommonModule, JslibModule, RouterModule],
|
||||
})
|
||||
export class ItemMoreOptionsComponent implements OnInit {
|
||||
private _cipher$ = new BehaviorSubject<CipherView>(undefined);
|
||||
|
||||
@Input({
|
||||
required: true,
|
||||
})
|
||||
cipher: CipherView;
|
||||
set cipher(c: CipherView) {
|
||||
this._cipher$.next(c);
|
||||
}
|
||||
|
||||
get cipher() {
|
||||
return this._cipher$.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag to hide the autofill menu options. Used for items that are
|
||||
@ -43,7 +52,15 @@ export class ItemMoreOptionsComponent implements OnInit {
|
||||
hideAutofillOptions: boolean;
|
||||
|
||||
protected autofillAllowed$ = this.vaultPopupAutofillService.autofillAllowed$;
|
||||
protected canClone$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Observable that emits a boolean value indicating if the user is authorized to clone the cipher.
|
||||
* @protected
|
||||
*/
|
||||
protected canClone$ = this._cipher$.pipe(
|
||||
filter((c) => c != null),
|
||||
switchMap((c) => this.cipherAuthorizationService.canCloneCipher$(c)),
|
||||
);
|
||||
|
||||
/** Boolean dependent on the current user having access to an organization */
|
||||
protected hasOrganizations = false;
|
||||
@ -63,7 +80,6 @@ export class ItemMoreOptionsComponent implements OnInit {
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.hasOrganizations = await this.organizationService.hasOrganizations();
|
||||
this.canClone$ = this.cipherAuthorizationService.canCloneCipher$(this.cipher);
|
||||
}
|
||||
|
||||
get canEdit() {
|
||||
|
@ -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 */
|
||||
|
@ -80,7 +80,7 @@
|
||||
"papaparse": "5.4.1",
|
||||
"proper-lockfile": "4.1.2",
|
||||
"rxjs": "7.8.1",
|
||||
"tldts": "6.1.58",
|
||||
"tldts": "6.1.60",
|
||||
"zxcvbn": "4.4.2"
|
||||
}
|
||||
}
|
||||
|
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.162"
|
||||
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"
|
||||
thiserror = "=1.0.68"
|
||||
tokio = { version = "=1.41.0", features = ["io-util", "sync", "macros"] }
|
||||
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.12"
|
||||
thiserror = "=1.0.68"
|
||||
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.12"
|
||||
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]
|
||||
|
@ -14,7 +14,7 @@
|
||||
"module-alias": "2.2.3",
|
||||
"node-ipc": "9.2.1",
|
||||
"ts-node": "10.9.2",
|
||||
"uuid": "11.0.1",
|
||||
"uuid": "11.0.3",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -421,9 +421,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.1.tgz",
|
||||
"integrity": "sha512-wt9UB5EcLhnboy1UvA1mvGPXkIIrHSu+3FmUksARfdVw9tuPf3CH/CohxO0Su1ApoKAeT6BVzAJIvjTuQVSmuQ==",
|
||||
"version": "11.0.3",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
|
||||
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
|
@ -19,7 +19,7 @@
|
||||
"module-alias": "2.2.3",
|
||||
"node-ipc": "9.2.1",
|
||||
"ts-node": "10.9.2",
|
||||
"uuid": "11.0.1",
|
||||
"uuid": "11.0.3",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -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,
|
||||
|
@ -2,7 +2,7 @@ import { ElectronLogMainService } from "./electron-log.main.service";
|
||||
|
||||
// Mock the use of the electron API to avoid errors
|
||||
jest.mock("electron", () => ({
|
||||
ipcMain: { handle: jest.fn() },
|
||||
ipcMain: { handle: jest.fn(), on: jest.fn() },
|
||||
}));
|
||||
|
||||
describe("ElectronLogMainService", () => {
|
||||
|
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">
|
||||
|
@ -262,7 +262,9 @@
|
||||
{{ "revokeAccess" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
*ngIf="editMode"
|
||||
*ngIf="
|
||||
editMode && (!(accountDeprovisioningEnabled$ | async) || !params.managedByOrganization)
|
||||
"
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="danger"
|
||||
@ -272,7 +274,9 @@
|
||||
[disabled]="loading"
|
||||
></button>
|
||||
<button
|
||||
*ngIf="editMode && params.managedByOrganization === true"
|
||||
*ngIf="
|
||||
editMode && (accountDeprovisioningEnabled$ | async) && params.managedByOrganization
|
||||
"
|
||||
type="button"
|
||||
bitIconButton="bwi-trash"
|
||||
buttonType="danger"
|
||||
|
@ -29,6 +29,8 @@ import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permi
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
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 { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
@ -125,6 +127,10 @@ export class MemberDialogComponent implements OnDestroy {
|
||||
manageResetPassword: false,
|
||||
});
|
||||
|
||||
protected accountDeprovisioningEnabled$: Observable<boolean> = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.AccountDeprovisioning,
|
||||
);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
get customUserTypeSelected(): boolean {
|
||||
@ -145,6 +151,7 @@ export class MemberDialogComponent implements OnDestroy {
|
||||
private accountService: AccountService,
|
||||
organizationService: OrganizationService,
|
||||
private toastService: ToastService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.organization$ = organizationService
|
||||
.get$(this.params.organizationId)
|
||||
|
@ -315,13 +315,18 @@
|
||||
<i aria-hidden="true" class="bwi bwi-minus-circle"></i>
|
||||
{{ "revokeAccess" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitMenuItem (click)="remove(u)">
|
||||
<button
|
||||
*ngIf="!(accountDeprovisioningEnabled$ | async) || !u.managedByOrganization"
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="remove(u)"
|
||||
>
|
||||
<span class="tw-text-danger">
|
||||
<i aria-hidden="true" class="bwi bwi-close"></i> {{ "remove" | i18n }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="u.managedByOrganization === true"
|
||||
*ngIf="(accountDeprovisioningEnabled$ | async) && u.managedByOrganization"
|
||||
type="button"
|
||||
bitMenuItem
|
||||
(click)="deleteUser(u)"
|
||||
|
@ -102,6 +102,10 @@ export class MembersComponent extends BaseMembersComponent<OrganizationUserView>
|
||||
FeatureFlag.EnableUpgradePasswordManagerSub,
|
||||
);
|
||||
|
||||
protected accountDeprovisioningEnabled$: Observable<boolean> = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.AccountDeprovisioning,
|
||||
);
|
||||
|
||||
// Fixed sizes used for cdkVirtualScroll
|
||||
protected rowHeight = 62;
|
||||
protected rowHeightClass = `tw-h-[62px]`;
|
||||
|
@ -46,7 +46,7 @@ export class SecretsManagerTrialFreeStepperComponent implements OnInit {
|
||||
protected formBuilder: UntypedFormBuilder,
|
||||
protected i18nService: I18nService,
|
||||
protected organizationBillingService: OrganizationBillingService,
|
||||
private router: Router,
|
||||
protected router: Router,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
@ -22,12 +22,29 @@
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[disabled]="formGroup.get('name').invalid"
|
||||
[loading]="createOrganizationLoading"
|
||||
(click)="createOrganizationOnTrial()"
|
||||
*ngIf="enableTrialPayment$ | async"
|
||||
>
|
||||
{{ "startTrial" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[disabled]="formGroup.get('name').invalid"
|
||||
[loading]="createOrganizationLoading"
|
||||
cdkStepperNext
|
||||
*ngIf="!(enableTrialPayment$ | async)"
|
||||
>
|
||||
{{ "next" | i18n }}
|
||||
</button>
|
||||
</app-vertical-step>
|
||||
<app-vertical-step label="{{ 'billing' | i18n | titlecase }}" [subLabel]="billingSubLabel">
|
||||
<app-vertical-step
|
||||
label="{{ 'billing' | i18n | titlecase }}"
|
||||
[subLabel]="billingSubLabel"
|
||||
*ngIf="!(enableTrialPayment$ | async)"
|
||||
>
|
||||
<app-trial-billing-step
|
||||
*ngIf="stepper.selectedIndex === 2"
|
||||
[organizationInfo]="{
|
||||
|
@ -1,6 +1,14 @@
|
||||
import { Component, Input, ViewChild } from "@angular/core";
|
||||
import { Component, Input, OnInit, ViewChild } from "@angular/core";
|
||||
import { UntypedFormBuilder } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
|
||||
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import {
|
||||
OrganizationCreatedEvent,
|
||||
@ -9,18 +17,64 @@ import {
|
||||
} from "../../../billing/accounts/trial-initiation/trial-billing-step.component";
|
||||
import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component";
|
||||
import { SecretsManagerTrialFreeStepperComponent } from "../secrets-manager/secrets-manager-trial-free-stepper.component";
|
||||
import { ValidOrgParams } from "../trial-initiation.component";
|
||||
|
||||
const trialFlowOrgs = [
|
||||
ValidOrgParams.teams,
|
||||
ValidOrgParams.teamsStarter,
|
||||
ValidOrgParams.enterprise,
|
||||
ValidOrgParams.families,
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: "app-secrets-manager-trial-paid-stepper",
|
||||
templateUrl: "secrets-manager-trial-paid-stepper.component.html",
|
||||
})
|
||||
export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrialFreeStepperComponent {
|
||||
export class SecretsManagerTrialPaidStepperComponent
|
||||
extends SecretsManagerTrialFreeStepperComponent
|
||||
implements OnInit
|
||||
{
|
||||
@ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent;
|
||||
@Input() organizationTypeQueryParameter: string;
|
||||
|
||||
plan: PlanType;
|
||||
createOrganizationLoading = false;
|
||||
billingSubLabel = this.i18nService.t("billingTrialSubLabel");
|
||||
organizationId: string;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
protected enableTrialPayment$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.TrialPaymentOptional,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private configService: ConfigService,
|
||||
protected formBuilder: UntypedFormBuilder,
|
||||
protected i18nService: I18nService,
|
||||
protected organizationBillingService: OrganizationBillingService,
|
||||
protected router: Router,
|
||||
) {
|
||||
super(formBuilder, i18nService, organizationBillingService, router);
|
||||
}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
this.referenceEventRequest = new ReferenceEventRequest();
|
||||
this.referenceEventRequest.initiationPath = "Secrets Manager trial from marketing website";
|
||||
|
||||
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => {
|
||||
if (trialFlowOrgs.includes(qParams.org)) {
|
||||
if (qParams.org === ValidOrgParams.teamsStarter) {
|
||||
this.plan = PlanType.TeamsStarter;
|
||||
} else if (qParams.org === ValidOrgParams.teams) {
|
||||
this.plan = PlanType.TeamsAnnually;
|
||||
} else if (qParams.org === ValidOrgParams.enterprise) {
|
||||
this.plan = PlanType.EnterpriseAnnually;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
organizationCreated(event: OrganizationCreatedEvent) {
|
||||
this.organizationId = event.organizationId;
|
||||
this.billingSubLabel = event.planDescription;
|
||||
@ -31,6 +85,29 @@ export class SecretsManagerTrialPaidStepperComponent extends SecretsManagerTrial
|
||||
this.verticalStepper.previous();
|
||||
}
|
||||
|
||||
async createOrganizationOnTrial(): Promise<void> {
|
||||
this.createOrganizationLoading = true;
|
||||
const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({
|
||||
organization: {
|
||||
name: this.formGroup.get("name").value,
|
||||
billingEmail: this.formGroup.get("email").value,
|
||||
initiationPath: "Secrets Manager trial from marketing website",
|
||||
},
|
||||
plan: {
|
||||
type: this.plan,
|
||||
subscribeToSecretsManager: true,
|
||||
isFromSecretsManagerTrial: true,
|
||||
passwordManagerSeats: 1,
|
||||
secretsManagerSeats: 1,
|
||||
},
|
||||
});
|
||||
|
||||
this.organizationId = response?.id;
|
||||
this.subLabels.organizationInfo = response?.name;
|
||||
this.createOrganizationLoading = false;
|
||||
this.verticalStepper.next();
|
||||
}
|
||||
|
||||
get createAccountLabel() {
|
||||
const organizationType =
|
||||
this.productType === ProductTierType.TeamsStarter
|
||||
|
@ -91,12 +91,17 @@
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
[disabled]="orgInfoFormGroup.get('name').invalid"
|
||||
cdkStepperNext
|
||||
[loading]="loading"
|
||||
(click)="createOrganizationOnTrial()"
|
||||
>
|
||||
{{ "next" | i18n }}
|
||||
{{ (enableTrialPayment$ | async) ? ("startTrial" | i18n) : ("next" | i18n) }}
|
||||
</button>
|
||||
</app-vertical-step>
|
||||
<app-vertical-step label="Billing" [subLabel]="billingSubLabel">
|
||||
<app-vertical-step
|
||||
label="Billing"
|
||||
[subLabel]="billingSubLabel"
|
||||
*ngIf="!(enableTrialPayment$ | async)"
|
||||
>
|
||||
<app-trial-billing-step
|
||||
*ngIf="stepper.selectedIndex === 2"
|
||||
[organizationInfo]="{
|
||||
|
@ -13,7 +13,9 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service";
|
||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||
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 { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
@ -39,6 +41,8 @@ describe("TrialInitiationComponent", () => {
|
||||
let policyServiceMock: MockProxy<PolicyService>;
|
||||
let routerServiceMock: MockProxy<RouterService>;
|
||||
let acceptOrgInviteServiceMock: MockProxy<AcceptOrganizationInviteService>;
|
||||
let organizationBillingServiceMock: MockProxy<OrganizationBillingService>;
|
||||
let configServiceMock: MockProxy<ConfigService>;
|
||||
|
||||
beforeEach(() => {
|
||||
// only define services directly that we want to mock return values in this component
|
||||
@ -47,6 +51,8 @@ describe("TrialInitiationComponent", () => {
|
||||
policyServiceMock = mock<PolicyService>();
|
||||
routerServiceMock = mock<RouterService>();
|
||||
acceptOrgInviteServiceMock = mock<AcceptOrganizationInviteService>();
|
||||
organizationBillingServiceMock = mock<OrganizationBillingService>();
|
||||
configServiceMock = mock<ConfigService>();
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
@ -92,6 +98,14 @@ describe("TrialInitiationComponent", () => {
|
||||
provide: AcceptOrganizationInviteService,
|
||||
useValue: acceptOrgInviteServiceMock,
|
||||
},
|
||||
{
|
||||
provide: OrganizationBillingService,
|
||||
useValue: organizationBillingServiceMock,
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: configServiceMock,
|
||||
},
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA], // Allows child components to be ignored (such as register component)
|
||||
}).compileComponents();
|
||||
|
@ -9,8 +9,15 @@ import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abs
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import {
|
||||
OrganizationInformation,
|
||||
PlanInformation,
|
||||
OrganizationBillingServiceAbstraction as OrganizationBillingService,
|
||||
} from "@bitwarden/common/billing/abstractions/organization-billing.service";
|
||||
import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request";
|
||||
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";
|
||||
|
||||
@ -25,7 +32,7 @@ import { OrganizationInvite } from "../organization-invite/organization-invite";
|
||||
import { RouterService } from "./../../core/router.service";
|
||||
import { VerticalStepperComponent } from "./vertical-stepper/vertical-stepper.component";
|
||||
|
||||
enum ValidOrgParams {
|
||||
export enum ValidOrgParams {
|
||||
families = "families",
|
||||
enterprise = "enterprise",
|
||||
teams = "teams",
|
||||
@ -69,6 +76,7 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
|
||||
productTier: ProductTierType;
|
||||
accountCreateOnly = true;
|
||||
useTrialStepper = false;
|
||||
loading = false;
|
||||
policies: Policy[];
|
||||
enforcedPolicyOptions: MasterPasswordPolicyOptions;
|
||||
trialFlowOrgs: string[] = [
|
||||
@ -115,6 +123,9 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
protected enableTrialPayment$ = this.configService.getFeatureFlag$(
|
||||
FeatureFlag.TrialPaymentOptional,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@ -127,6 +138,8 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
|
||||
private i18nService: I18nService,
|
||||
private routerService: RouterService,
|
||||
private acceptOrgInviteService: AcceptOrganizationInviteService,
|
||||
private organizationBillingService: OrganizationBillingService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@ -215,6 +228,30 @@ export class TrialInitiationComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async createOrganizationOnTrial() {
|
||||
this.loading = true;
|
||||
const organization: OrganizationInformation = {
|
||||
name: this.orgInfoFormGroup.get("name").value,
|
||||
billingEmail: this.orgInfoFormGroup.get("email").value,
|
||||
initiationPath: "Password Manager trial from marketing website",
|
||||
};
|
||||
|
||||
const plan: PlanInformation = {
|
||||
type: this.plan,
|
||||
passwordManagerSeats: 1,
|
||||
};
|
||||
|
||||
const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({
|
||||
organization,
|
||||
plan,
|
||||
});
|
||||
|
||||
this.orgId = response?.id;
|
||||
this.billingSubLabel = `${this.i18nService.t("annual")} ($0/${this.i18nService.t("yr")})`;
|
||||
this.loading = false;
|
||||
this.verticalStepper.next();
|
||||
}
|
||||
|
||||
createdAccount(email: string) {
|
||||
this.email = email;
|
||||
this.orgInfoFormGroup.get("email")?.setValue(email);
|
||||
|
@ -345,16 +345,22 @@
|
||||
<a></a>
|
||||
</p>
|
||||
<app-payment
|
||||
*ngIf="(upgradeRequiresPaymentMethod || showPayment) && !deprecateStripeSourcesAPI"
|
||||
*ngIf="
|
||||
(upgradeRequiresPaymentMethod || showPayment || isPaymentSourceEmpty()) &&
|
||||
!deprecateStripeSourcesAPI
|
||||
"
|
||||
[hideCredit]="true"
|
||||
></app-payment>
|
||||
<app-payment-v2
|
||||
*ngIf="(upgradeRequiresPaymentMethod || showPayment) && deprecateStripeSourcesAPI"
|
||||
*ngIf="
|
||||
(upgradeRequiresPaymentMethod || showPayment || isPaymentSourceEmpty()) &&
|
||||
deprecateStripeSourcesAPI
|
||||
"
|
||||
[showAccountCredit]="false"
|
||||
>
|
||||
</app-payment-v2>
|
||||
<app-tax-info
|
||||
*ngIf="showPayment || upgradeRequiresPaymentMethod"
|
||||
*ngIf="showPayment || upgradeRequiresPaymentMethod || isPaymentSourceEmpty()"
|
||||
(onCountryChanged)="changedCountry()"
|
||||
></app-tax-info>
|
||||
<div id="price" class="tw-mt-4">
|
||||
|
@ -282,6 +282,12 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
: this.discountPercentageFromSub + this.discountPercentage;
|
||||
}
|
||||
|
||||
isPaymentSourceEmpty() {
|
||||
return this.deprecateStripeSourcesAPI
|
||||
? this.paymentSource === null || this.paymentSource === undefined
|
||||
: this.billing?.paymentSource === null || this.billing?.paymentSource === undefined;
|
||||
}
|
||||
|
||||
isSecretsManagerTrial(): boolean {
|
||||
return (
|
||||
this.sub?.subscription?.items?.some((item) =>
|
||||
@ -723,7 +729,7 @@ export class ChangePlanDialogComponent implements OnInit, OnDestroy {
|
||||
// Secrets Manager
|
||||
this.buildSecretsManagerRequest(request);
|
||||
|
||||
if (this.upgradeRequiresPaymentMethod || this.showPayment) {
|
||||
if (this.upgradeRequiresPaymentMethod || this.showPayment || this.isPaymentSourceEmpty()) {
|
||||
if (this.deprecateStripeSourcesAPI) {
|
||||
const tokenizedPaymentSource = await this.paymentV2Component.tokenize();
|
||||
const updatePaymentMethodRequest = new UpdatePaymentMethodRequest();
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { BannerModule } from "../../../../../../libs/components/src/banner/banner.module";
|
||||
import { UserVerificationModule } from "../../auth/shared/components/user-verification";
|
||||
import { LooseComponentsModule } from "../../shared";
|
||||
import { BillingSharedModule } from "../shared";
|
||||
@ -28,6 +29,7 @@ import { SubscriptionStatusComponent } from "./subscription-status.component";
|
||||
BillingSharedModule,
|
||||
OrganizationPlansComponent,
|
||||
LooseComponentsModule,
|
||||
BannerModule,
|
||||
],
|
||||
declarations: [
|
||||
AdjustSubscription,
|
||||
|
@ -1,3 +1,22 @@
|
||||
<bit-banner
|
||||
id="free-trial-banner"
|
||||
class="-tw-m-6 tw-flex tw-flex-col tw-pb-6"
|
||||
bannerType="premium"
|
||||
icon="bwi-billing"
|
||||
[showClose]="false"
|
||||
*ngIf="freeTrialData?.shownBanner"
|
||||
>
|
||||
{{ freeTrialData.message }}
|
||||
<a
|
||||
bitLink
|
||||
linkType="contrast"
|
||||
(click)="changePayment()"
|
||||
class="tw-cursor-pointer"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{{ "routeToPaymentMethodTrigger" | i18n }}
|
||||
</a>
|
||||
</bit-banner>
|
||||
<app-header></app-header>
|
||||
<bit-container>
|
||||
<ng-container *ngIf="loading">
|
||||
|
@ -1,17 +1,25 @@
|
||||
import { Component, ViewChild } from "@angular/core";
|
||||
import { Location } from "@angular/common";
|
||||
import { Component, OnDestroy, ViewChild } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { from, lastValueFrom, switchMap } from "rxjs";
|
||||
|
||||
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 { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PaymentMethodType } from "@bitwarden/common/billing/enums";
|
||||
import { ExpandedTaxInfoUpdateRequest } from "@bitwarden/common/billing/models/request/expanded-tax-info-update.request";
|
||||
import { VerifyBankAccountRequest } from "@bitwarden/common/billing/models/request/verify-bank-account.request";
|
||||
import { OrganizationSubscriptionResponse } from "@bitwarden/common/billing/models/response/organization-subscription.response";
|
||||
import { PaymentSourceResponse } from "@bitwarden/common/billing/models/response/payment-source.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
import { FreeTrial } from "../../../core/types/free-trial";
|
||||
import { TrialFlowService } from "../../services/trial-flow.service";
|
||||
import { TaxInfoComponent } from "../../shared";
|
||||
import {
|
||||
AddCreditDialogResult,
|
||||
@ -25,26 +33,36 @@ import {
|
||||
@Component({
|
||||
templateUrl: "./organization-payment-method.component.html",
|
||||
})
|
||||
export class OrganizationPaymentMethodComponent {
|
||||
export class OrganizationPaymentMethodComponent implements OnDestroy {
|
||||
@ViewChild(TaxInfoComponent) taxInfoComponent: TaxInfoComponent;
|
||||
|
||||
organizationId: string;
|
||||
isUnpaid = false;
|
||||
accountCredit: number;
|
||||
paymentSource?: PaymentSourceResponse;
|
||||
subscriptionStatus?: string;
|
||||
protected freeTrialData: FreeTrial;
|
||||
organization: Organization;
|
||||
organizationSubscriptionResponse: OrganizationSubscriptionResponse;
|
||||
|
||||
loading = true;
|
||||
|
||||
protected readonly Math = Math;
|
||||
launchPaymentModalAutomatically = false;
|
||||
|
||||
constructor(
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
protected organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private router: Router,
|
||||
private toastService: ToastService,
|
||||
private location: Location,
|
||||
private trialFlowService: TrialFlowService,
|
||||
private organizationService: OrganizationService,
|
||||
protected syncService: SyncService,
|
||||
) {
|
||||
this.activatedRoute.params
|
||||
.pipe(
|
||||
@ -59,6 +77,23 @@ export class OrganizationPaymentMethodComponent {
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
const state = this.router.getCurrentNavigation()?.extras?.state;
|
||||
// incase the above state is undefined or null we use redundantState
|
||||
const redundantState: any = location.getState();
|
||||
if (state && Object.prototype.hasOwnProperty.call(state, "launchPaymentModalAutomatically")) {
|
||||
this.launchPaymentModalAutomatically = state.launchPaymentModalAutomatically;
|
||||
} else if (
|
||||
redundantState &&
|
||||
Object.prototype.hasOwnProperty.call(redundantState, "launchPaymentModalAutomatically")
|
||||
) {
|
||||
this.launchPaymentModalAutomatically = redundantState.launchPaymentModalAutomatically;
|
||||
} else {
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
}
|
||||
}
|
||||
ngOnDestroy(): void {
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
}
|
||||
|
||||
protected addAccountCredit = async (): Promise<void> => {
|
||||
@ -82,6 +117,34 @@ export class OrganizationPaymentMethodComponent {
|
||||
this.accountCredit = accountCredit;
|
||||
this.paymentSource = paymentSource;
|
||||
this.subscriptionStatus = subscriptionStatus;
|
||||
|
||||
if (this.organizationId) {
|
||||
const organizationSubscriptionPromise = this.organizationApiService.getSubscription(
|
||||
this.organizationId,
|
||||
);
|
||||
const organizationPromise = this.organizationService.get(this.organizationId);
|
||||
|
||||
[this.organizationSubscriptionResponse, this.organization] = await Promise.all([
|
||||
organizationSubscriptionPromise,
|
||||
organizationPromise,
|
||||
]);
|
||||
this.freeTrialData = this.trialFlowService.checkForOrgsWithUpcomingPaymentIssues(
|
||||
this.organization,
|
||||
this.organizationSubscriptionResponse,
|
||||
paymentSource,
|
||||
);
|
||||
}
|
||||
this.isUnpaid = this.subscriptionStatus === "unpaid" ?? false;
|
||||
// If the flag `launchPaymentModalAutomatically` is set to true,
|
||||
// we schedule a timeout (delay of 800ms) to automatically launch the payment modal.
|
||||
// This delay ensures that any prior UI/rendering operations complete before triggering the modal.
|
||||
if (this.launchPaymentModalAutomatically) {
|
||||
window.setTimeout(async () => {
|
||||
await this.changePayment();
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
this.location.replaceState(this.location.path(), "", {});
|
||||
}, 800);
|
||||
}
|
||||
this.loading = false;
|
||||
};
|
||||
|
||||
@ -100,6 +163,24 @@ export class OrganizationPaymentMethodComponent {
|
||||
}
|
||||
};
|
||||
|
||||
changePayment = async () => {
|
||||
const dialogRef = AdjustPaymentDialogV2Component.open(this.dialogService, {
|
||||
data: {
|
||||
initialPaymentMethod: this.paymentSource?.type,
|
||||
organizationId: this.organizationId,
|
||||
},
|
||||
});
|
||||
const result = await lastValueFrom(dialogRef.closed);
|
||||
if (result === AdjustPaymentDialogV2ResultType.Submitted) {
|
||||
this.location.replaceState(this.location.path(), "", {});
|
||||
if (this.launchPaymentModalAutomatically && !this.organization.enabled) {
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
this.launchPaymentModalAutomatically = false;
|
||||
await this.load();
|
||||
}
|
||||
};
|
||||
|
||||
protected updateTaxInformation = async (): Promise<void> => {
|
||||
this.taxInfoComponent.taxFormGroup.updateValueAndValidity();
|
||||
this.taxInfoComponent.taxFormGroup.markAllAsTouched();
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user