1
0
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:
Matt Bishop 2024-11-11 17:38:32 -05:00 committed by GitHub
commit 72eba79799
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
275 changed files with 7769 additions and 966 deletions

3
.github/CODEOWNERS vendored
View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -277,6 +277,7 @@ export class VaultPopupItemsService {
[CipherType.Card]: 2,
[CipherType.Identity]: 3,
[CipherType.SecureNote]: 4,
[CipherType.SshKey]: 5,
};
// Compare types first

View File

@ -97,6 +97,7 @@ describe("VaultPopupListFiltersService", () => {
CipherType.Card,
CipherType.Identity,
CipherType.SecureNote,
CipherType.SshKey,
]);
});
});

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -11,3 +11,6 @@ pub mod password;
pub mod process_isolation;
#[cfg(feature = "sys")]
pub mod powermonitor;
#[cfg(feature = "sys")]
pub mod ssh_agent;

View File

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

View File

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

View File

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

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

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

View 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))),
}
}

View File

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

View File

@ -0,0 +1,8 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQRQzzQ8nQEouF1FMSHkPx1nejNCzF7g
Yb8MHXLdBFM0uJkWs0vzgLJkttts2eDv3SHJqIH6qHpkLtEvgMXE5WcaAAAAoOO1BebjtQ
XmAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFDPNDydASi4XUUx
IeQ/HWd6M0LMXuBhvwwdct0EUzS4mRazS/OAsmS222zZ4O/dIcmogfqoemQu0S+AxcTlZx
oAAAAhAKnIXk6H0Hs3HblklaZ6UmEjjdE/0t7EdYixpMmtpJ4eAAAAB3Rlc3RrZXk=
-----END OPENSSH PRIVATE KEY-----

View File

@ -0,0 +1 @@
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFDPNDydASi4XUUxIeQ/HWd6M0LMXuBhvwwdct0EUzS4mRazS/OAsmS222zZ4O/dIcmogfqoemQu0S+AxcTlZxo= testkey

View File

@ -0,0 +1,8 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABAUTNb0if
fqsoqtfv70CfukAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIHGs3Uw3eyqnFjBI
2eb7Qto4KVc34ZdnBac59Bab54BLAAAAkPA6aovfxQbP6FoOfaRH6u22CxqiUM0bbMpuFf
WETn9FLaBE6LjoHH0ZI5rzNjJaQUNfx0cRcqsIrexw8YINrdVjySmEqrl5hw8gpgy0gGP5
1Y6vKWdHdrxJCA9YMFOfDs0UhPfpLKZCwm2Sg+Bd8arlI8Gy7y4Jj/60v2bZOLhD2IZQnK
NdJ8xATiIINuTy4g==
-----END OPENSSH PRIVATE KEY-----

View File

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHGs3Uw3eyqnFjBI2eb7Qto4KVc34ZdnBac59Bab54BL testkey

View File

@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAyQo22TXXNqvF+L8jUSSNeu8UqrsDjvf9pwIwDC9ML6gAAAJDSHpL60h6S
+gAAAAtzc2gtZWQyNTUxOQAAACAyQo22TXXNqvF+L8jUSSNeu8UqrsDjvf9pwIwDC9ML6g
AAAECLdlFLIJbEiFo/f0ROdXMNZAPHGPNhvbbftaPsUZEjaDJCjbZNdc2q8X4vyNRJI167
xSquwOO9/2nAjAML0wvqAAAAB3Rlc3RrZXkBAgMEBQY=
-----END OPENSSH PRIVATE KEY-----

View File

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDJCjbZNdc2q8X4vyNRJI167xSquwOO9/2nAjAML0wvq testkey

View File

@ -0,0 +1,4 @@
-----BEGIN PRIVATE KEY-----
MFECAQEwBQYDK2VwBCIEIDY6/OAdDr3PbDss9NsLXK4CxiKUvz5/R9uvjtIzj4Sz
gSEAxsxm1xpZ/4lKIRYm0JrJ5gRZUh7H24/YT/0qGVGzPa0=
-----END PRIVATE KEY-----

View File

@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMbMZtcaWf+JSiEWJtCayeYEWVIex9uP2E/9KhlRsz2t

View File

@ -0,0 +1,39 @@
-----BEGIN OPENSSH PRIVATE KEY-----
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
2MmKBw==
-----END OPENSSH PRIVATE KEY-----

View File

@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC/v18xGP3qzRV9iWqyiuwHZ4GpC4K2NO2/i2Yv5A3/bnal7CmiMh/S78lphgxcWtFkwrwlb321FmdHBv6KOW+EzSiPvmsdkkbpfBXB3Qf2SlhZOZZ7lYeu8KAxL3exvvn8O1GGlUjXGUrFgmC60tHWDBc1Ncmo8a2dwDLmA/sbLa8su2dvYEFmRg1vaytLDpkn8GS7zAxrUl/g0W2RwkPsByduUziQuX90v9WAy7MqOlwBRq6t5o8wdDBVODe0VIXC7N1OS42YUsKF+N0XOnLiJrIIKkXpahMDpKZHeHQAdUQzsJVhKoLJR8DNDTYyhnJoQG7Q6m2gDTca9oAWvsBiNoEwCvwrt7cDNCz/GslH9HXQgfWcVXn8+fuZgvjO3CxUI16Ev33m0jWoOKJcgK/ZLRnk8SEvsJ8NO32MeR/qUb7IN/yUcDmPMI/3ecQsakF2cwNzHkyiGVo//yVTpf+vk8b89L+GXbYU5rtswtc2ZEGsQnUkaoNqS8mHqhWQBUk= testkey

View File

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

View File

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

View File

@ -0,0 +1,42 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
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
-----END ENCRYPTED PRIVATE KEY-----

View File

@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCcHkc0xfH4w9aW41S9M/BfancSY4QPc2O4G1cRjFfK8QrLEGDA7NiHtoEML0afcurRXD3NVxuKaAns0w6EoS4CjzXUqVHTLA4SUyuapr8k0Eu2xOpbCwC3jDovhckoKloq7BvE6rC2i5wjSMadtIJKt/dqWI3HLjUMz1BxQJAU/qAbicj1SFZSjA/MubVBzcq93XOvByMtlIFu7wami3FTc37rVkGeUFHtK8ZbvG3n1aaTF79bBgSPuoq5BfcMdGr4WfQyGQzgse4v4hQ8yKYrtE0jo0kf06hEORimwOIU/W5IH1r+/xFs7qGKcPnFSZRIFv5LfMPTo8b+OsBRflosyfUumDEX97GZE7DSQl0EJzNvWeKwl7dQ8RUJTkbph2CjrxY77DFim+165Uj/WRr4uq2qMNhA2xNSD19+TA6AHdpGw4WZd37q2/n+EddlaJEH8MzpgtHNG9MiYh5ScZ+AG0QugflozJcQNc7n8N9Lpu1sRoejV5RhurHg/TYwVK8= testkey

View File

@ -0,0 +1,40 @@
-----BEGIN PRIVATE KEY-----
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
QjDEB37RqGZxqyIx8V2ZYwU=
-----END PRIVATE KEY-----

View File

@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCn4+QiJojZ9mgc9KYJIvDWGaz4qFhf0CButg6L8zEoHKwuiN+mqcEciCCOa9BNiJmm8NTTehZvrrglGG59zIbqYtDAHjVn+vtb49xPzIv+M651Yqj08lIbR9tEIHKCq7aH8GlDm8NgG9EzJGjlL7okQym4TH1MHl+s4mUyr/qb2unlZBDixAQsphU8iCLftukWCIkmQg4CSj1Gh3WbBlZ+EX5eW0EXuAw4XsSbBTWV9CHRowVIpYqPvEYSpHsoCjEcd988p19hpiGknA0J4z7JfUlNgyT/1chb8GCTDT+2DCBRApbsIg6TOBVS+PR6emAQ3eZzUW0+3/oRM4ip0ujltQy8uU6gvYIAqx5wXGMThVpZcUgahKiSsVo/s4b84iMe4DG3W8jz4qi6yyNv0VedEzPUZ1lXd1GJFoy9uKNuSTe+1ksicAcluZN6LuNsPHcPxFCzOcmoNnVXEKAXInt+ys//5CDVasroZSAHZnDjUD4oNsLI3VIOnGxgXrkwSH0= testkey

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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>&nbsp;{{ "typeSshKey" | i18n }}
</button>
</span>
</li>
</ul>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]="{

View File

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

View File

@ -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]="{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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