Merge branch 'master' into bug/ps-136-search-accented-characters-clients

This commit is contained in:
gbubemismith 2022-05-26 01:44:32 +01:00
commit f345eca0d0
95 changed files with 17760 additions and 6 deletions

View File

@ -7,3 +7,9 @@
521feae535d83166e620c3c28dfc3e7b0314a00e
# Browser: Monorepository https://github.com/bitwarden/clients/commit/7712ca6fa505c4b80b168258a7fc6a02c13160fd
28bc4113b9bbae4dba2b5af14d460764fce79acf
# CLI: Apply Prettier https://github.com/bitwarden/cli/pull/426
910b4a24e649f21acbf4da5b2d422b121d514bd5
# CLI: Monorepository https://github.com/bitwarden/clients/commit/980429f4bdcb178d8d92d8202cbdacfaa45c917e
980429f4bdcb178d8d92d8202cbdacfaa45c917e

82
.github/ISSUE_TEMPLATE/cli.yml vendored Normal file
View File

@ -0,0 +1,82 @@
name: CLI Bug Report
description: File a bug report
labels: [bug, cli]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
Please do not submit feature requests. The [Community Forums](https://community.bitwarden.com) has a section for submitting, voting for, and discussing product feature requests.
- type: textarea
id: reproduce
attributes:
label: Steps To Reproduce
description: How can we reproduce the behavior.
value: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. Click on '...'
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Result
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Result
description: A clear and concise description of what is happening.
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots or Videos
description: If applicable, add screenshots and/or a short video to help explain your problem.
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context about the problem here.
- type: dropdown
id: os
attributes:
label: Operating System
description: What operating system are you seeing the problem on?
multiple: true
options:
- Windows
- macOS
- Linux
validations:
required: true
- type: input
id: os-version
attributes:
label: Operating System Version
description: What version of the operating system(s) are you seeing the problem on?
- type: dropdown
id: shell
attributes:
label: Shell
description: What shell(s) are you seeing the problem on?
multiple: true
options:
- Bash
- Zsh
- PowerShell
validations:
required: true
- type: input
id: version
attributes:
label: Build Version
description: What version of our software are you running? (run `bw --version`)
validations:
required: true

View File

@ -10,9 +10,10 @@ on:
- 'apps/browser/**'
- '.github/workflows/build-browser.yml'
push:
branches-ignore:
- 'l10n_master'
- 'gh-pages'
branches:
- 'master'
- 'rc'
- 'hotfix-rc/**'
paths:
- 'apps/browser/**'
- '.github/workflows/build-browser.yml'

View File

@ -10,9 +10,10 @@ on:
- 'apps/desktop/**'
- '.github/workflows/build-desktop.yml'
push:
branches-ignore:
- 'l10n_master'
- 'gh-pages'
branches:
- 'master'
- 'rc'
- 'hotfix-rc/**'
paths:
- 'apps/desktop/**'
- '.github/workflows/build-desktop.yml'

5
.gitmodules vendored
View File

@ -7,3 +7,8 @@
path = apps/desktop/jslib
url = https://github.com/bitwarden/jslib.git
branch = master
[submodule "apps/cli/jslib"]
path = apps/cli/jslib
url = https://github.com/bitwarden/jslib.git
branch = master

View File

@ -16,5 +16,8 @@ apps/desktop/dist-safari
apps/desktop/desktop_native
apps/desktop/src/scripts/duo.js
apps/cli/src/locales
apps/cli/.github
# Github Workflows
.github/workflows

View File

@ -69,3 +69,24 @@ git merge clients/master
# Push to clients or your own fork
```
### CLI
```
# Merge master
git merge master
# Merge branch mono-repo-prep
git merge 980429f4bdcb178d8d92d8202cbdacfaa45c917e
# Verify files are placed in apps/cli
# Add remote
git remote add clients git@github.com:bitwarden/clients.git
# Merge against clients master
git fetch clients
git merge clients/master
# Push to clients or your own fork
```

5
apps/cli/.eslintrc.json Normal file
View File

@ -0,0 +1,5 @@
{
"env": {
"node": true
}
}

389
apps/cli/.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,389 @@
---
name: Build
on:
push:
branches-ignore:
- 'l10n_master'
paths-ignore:
- '.github/workflows/**'
workflow_dispatch:
inputs: {}
jobs:
cloc:
name: CLOC
runs-on: ubuntu-20.04
steps:
- name: Checkout repo
uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846
- name: Set up cloc
run: |
sudo apt update
sudo apt -y install cloc
- name: Print lines of code
run: cloc --include-lang TypeScript,JavaScript --vcs git
setup:
name: Setup
runs-on: ubuntu-20.04
outputs:
package_version: ${{ steps.retrieve-version.outputs.package_version }}
steps:
- name: Checkout repo
uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846
- name: Get Package Version
id: retrieve-version
run: |
PKG_VERSION=$(jq -r .version package.json)
echo "::set-output name=package_version::$PKG_VERSION"
lint:
name: Lint
runs-on: ubuntu-20.04
steps:
- name: Checkout repo
uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846
- name: Cache npm
id: npm-cache
uses: actions/cache@937d24475381cd9c75ae6db12cb4e79714b926ed
with:
path: '~/.npm'
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Setup sub-module
run: npm run sub:init
- name: Install
run: npm ci
- name: Run linter
run: npm run lint
cli:
name: Build CLI
runs-on: windows-2019
needs:
- setup
- lint
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_WIN_PKG_FETCH_VERSION: 16.14.2
_WIN_PKG_VERSION: 3.3
steps:
- name: Checkout repo
uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846
- name: Setup Windows builder
run: |
choco install checksum --no-progress
choco install reshack --no-progress
- name: Set up Node
uses: actions/setup-node@9ced9a43a244f3ac94f13bfd896db8c8f30da67a # v3.0.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
node-version: '16'
- name: Get pkg-fetch
shell: pwsh
run: |
cd $HOME
$fetchedUrl = "https://github.com/vercel/pkg-fetch/releases/download/v$env:_WIN_PKG_VERSION/node-v$env:_WIN_PKG_FETCH_VERSION-win-x64"
New-Item -ItemType directory -Path .\.pkg-cache
New-Item -ItemType directory -Path .\.pkg-cache\v$env:_WIN_PKG_VERSION
Invoke-RestMethod -Uri $fetchedUrl `
-OutFile ".\.pkg-cache\v$env:_WIN_PKG_VERSION\fetched-v$env:_WIN_PKG_FETCH_VERSION-win-x64"
- name: Setup Version Info
shell: pwsh
run: |
$major,$minor,$patch = $env:_PACKAGE_VERSION.split('.')
$versionInfo = @"
1 VERSIONINFO
FILEVERSION $major,$minor,$patch,0
PRODUCTVERSION $major,$minor,$patch,0
FILEOS 0x40004
FILETYPE 0x1
{
BLOCK "StringFileInfo"
{
BLOCK "040904b0"
{
VALUE "CompanyName", "Bitwarden Inc."
VALUE "ProductName", "Bitwarden"
VALUE "FileDescription", "Bitwarden CLI"
VALUE "FileVersion", "$env:_PACKAGE_VERSION"
VALUE "ProductVersion", "$env:_PACKAGE_VERSION"
VALUE "OriginalFilename", "bw.exe"
VALUE "InternalName", "bw"
VALUE "LegalCopyright", "Copyright Bitwarden Inc."
}
}
BLOCK "VarFileInfo"
{
VALUE "Translation", 0x0409 0x04B0
}
}
"@
$versionInfo | Out-File ./version-info.rc
# https://github.com/vercel/pkg-fetch/issues/188
- name: Resource Hacker
shell: cmd
run: |
set PATH=%PATH%;C:\Program Files (x86)\Resource Hacker
set WIN_PKG=C:\Users\runneradmin\.pkg-cache\v%_WIN_PKG_VERSION%\fetched-v%_WIN_PKG_FETCH_VERSION%-win-x64
set WIN_PKG_BUILT=C:\Users\runneradmin\.pkg-cache\v%_WIN_PKG_VERSION%\built-v%_WIN_PKG_FETCH_VERSION%-win-x64
copy %WIN_PKG% %WIN_PKG_BUILT%
ResourceHacker -open %WIN_PKG_BUILT% -save %WIN_PKG_BUILT% -action delete -mask ICONGROUP,1,
ResourceHacker -open version-info.rc -save version-info.res -action compile
ResourceHacker -open %WIN_PKG_BUILT% -save %WIN_PKG_BUILT% -action addoverwrite -resource version-info.res
- name: Setup sub-module
run: npm run sub:init
- name: Install
run: npm ci
- name: Run tests
run: npm run test
- name: Build & Package
run: npm run dist
- name: Package Chocolatey
shell: pwsh
run: |
Copy-Item -Path stores/chocolatey -Destination dist/chocolatey -Recurse
Copy-Item dist/windows/bw.exe -Destination dist/chocolatey/tools
Copy-Item LICENSE.txt -Destination dist/chocolatey/tools
choco pack dist/chocolatey/bitwarden-cli.nuspec --version ${{ env._PACKAGE_VERSION }} --out dist/chocolatey
- name: Zip
shell: cmd
run: |
7z a ./dist/bw-windows-%_PACKAGE_VERSION%.zip ./dist/windows/bw.exe
7z a ./dist/bw-macos-%_PACKAGE_VERSION%.zip ./dist/macos/bw
7z a ./dist/bw-linux-%_PACKAGE_VERSION%.zip ./dist/linux/bw
- name: Version Test
run: |
dir ./dist/
Expand-Archive -Path "./dist/bw-windows-${env:_PACKAGE_VERSION}.zip" -DestinationPath "./test/windows"
$testVersion = Invoke-Expression '& ./test/windows/bw.exe -v'
echo "version: $env:_PACKAGE_VERSION"
echo "testVersion: $testVersion"
if($testVersion -ne $env:_PACKAGE_VERSION) {
Throw "Version test failed."
}
- name: Create checksums
run: |
checksum -f="./dist/bw-windows-${env:_PACKAGE_VERSION}.zip" `
-t sha256 | Out-File -Encoding ASCII ./dist/bw-windows-sha256-${env:_PACKAGE_VERSION}.txt
checksum -f="./dist/bw-macos-${env:_PACKAGE_VERSION}.zip" `
-t sha256 | Out-File -Encoding ASCII ./dist/bw-macos-sha256-${env:_PACKAGE_VERSION}.txt
checksum -f="./dist/bw-linux-${env:_PACKAGE_VERSION}.zip" `
-t sha256 | Out-File -Encoding ASCII ./dist/bw-linux-sha256-${env:_PACKAGE_VERSION}.txt
- name: Upload windows zip asset
uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with:
name: bw-windows-${{ env._PACKAGE_VERSION }}.zip
path: ./dist/bw-windows-${{ env._PACKAGE_VERSION }}.zip
if-no-files-found: error
- name: Upload windows checksum asset
uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with:
name: bw-windows-sha256-${{ env._PACKAGE_VERSION }}.txt
path: ./dist/bw-windows-sha256-${{ env._PACKAGE_VERSION }}.txt
if-no-files-found: error
- name: Upload macos zip asset
uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with:
name: bw-macos-${{ env._PACKAGE_VERSION }}.zip
path: ./dist/bw-macos-${{ env._PACKAGE_VERSION }}.zip
if-no-files-found: error
- name: Upload macos checksum asset
uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with:
name: bw-macos-sha256-${{ env._PACKAGE_VERSION }}.txt
path: ./dist/bw-macos-sha256-${{ env._PACKAGE_VERSION }}.txt
if-no-files-found: error
- name: Upload linux zip asset
uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with:
name: bw-linux-${{ env._PACKAGE_VERSION }}.zip
path: ./dist/bw-linux-${{ env._PACKAGE_VERSION }}.zip
if-no-files-found: error
- name: Upload linux checksum asset
uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with:
name: bw-linux-sha256-${{ env._PACKAGE_VERSION }}.txt
path: ./dist/bw-linux-sha256-${{ env._PACKAGE_VERSION }}.txt
if-no-files-found: error
- name: Upload Chocolatey asset
uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with:
name: bitwarden-cli.${{ env._PACKAGE_VERSION }}.nupkg
path: ./dist/chocolatey/bitwarden-cli.${{ env._PACKAGE_VERSION }}.nupkg
if-no-files-found: error
- name: Upload NPM Build Directory asset
uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with:
name: bitwarden-cli-${{ env._PACKAGE_VERSION }}-npm-build.zip
path: ./build
if-no-files-found: error
snap:
name: Build Snap
runs-on: ubuntu-20.04
needs: [setup, cli]
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
steps:
- name: Checkout repo
uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846
- name: Print environment
run: |
whoami
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
echo "BW Package Version: $_PACKAGE_VERSION"
- name: Get bw linux cli
uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
with:
name: bw-linux-${{ env._PACKAGE_VERSION }}.zip
path: ./dist/snap
- name: Setup Snap Package
run: |
cp -r stores/snap/* -t dist/snap
sed -i s/__version__/${{ env._PACKAGE_VERSION }}/g dist/snap/snapcraft.yaml
cd dist/snap
ls -alth
- name: Build snap
uses: snapcore/action-build@a400bf1c2d0f23074aaacf08a144813c3c20b35d # v1.0.9
with:
path: dist/snap
- name: Create checksum
run: |
cd dist/snap
ls -alth
sha256sum bw_${{ env._PACKAGE_VERSION }}_amd64.snap \
| awk '{split($0, a); print a[1]}' > bw-snap-sha256-${{ env._PACKAGE_VERSION }}.txt
- name: Install Snap
run: sudo snap install dist/snap/bw*.snap --dangerous
- name: Test Snap
shell: pwsh
run: |
$testVersion = Invoke-Expression '& bw -v'
if($testVersion -ne $env:_PACKAGE_VERSION) {
Throw "Version test failed."
}
env:
BITWARDENCLI_APPDATA_DIR: "/home/runner/snap/bw/x1/.config/Bitwarden CLI"
- name: Cleanup Test & Update Snap for Publish
shell: pwsh
run: sudo snap remove bw
- name: Upload snap asset
uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with:
name: bw_${{ env._PACKAGE_VERSION }}_amd64.snap
path: ./dist/snap/bw_${{ env._PACKAGE_VERSION }}_amd64.snap
if-no-files-found: error
- name: Upload snap checksum asset
uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with:
name: bw-snap-sha256-${{ env._PACKAGE_VERSION }}.txt
path: ./dist/snap/bw-snap-sha256-${{ env._PACKAGE_VERSION }}.txt
if-no-files-found: error
check-failures:
name: Check for failures
if: always()
runs-on: ubuntu-20.04
needs:
- cloc
- setup
- lint
- cli
- snap
steps:
- name: Check if any job failed
if: ${{ (github.ref == 'refs/heads/master') || (github.ref == 'refs/heads/rc') }}
env:
CLOC_STATUS: ${{ needs.cloc.result }}
SETUP_STATUS: ${{ needs.setup.result }}
LINT_STATUS: ${{ needs.lint.result }}
CLI_STATUS: ${{ needs.cli.result }}
SNAP_STATUS: ${{ needs.snap.result }}
run: |
if [ "$CLOC_STATUS" = "failure" ]; then
exit 1
elif [ "$SETUP_STATUS" = "failure" ]; then
exit 1
elif [ "$LINT_STATUS" = "failure" ]; then
exit 1
elif [ "$CLI_STATUS" = "failure" ]; then
exit 1
elif [ "$SNAP_STATUS" = "failure" ]; then
exit 1
fi
- name: Login to Azure - Prod Subscription
uses: Azure/login@1f63701bf3e6892515f1b7ce2d2bf1708b46beaf
if: failure()
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Retrieve secrets
id: retrieve-secrets
uses: Azure/get-keyvault-secrets@b5c723b9ac7870c022b8c35befe620b7009b336f
if: failure()
with:
keyvault: "bitwarden-prod-kv"
secrets: "devops-alerts-slack-webhook-url"
- name: Notify Slack on failure
uses: act10ns/slack@da3191ebe2e67f49b46880b4633f5591a96d1d33
if: failure()
env:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
with:
status: ${{ job.status }}

View File

@ -0,0 +1,16 @@
---
name: Enforce PR labels
on:
pull_request:
types: [labeled, unlabeled, opened, edited, synchronize]
jobs:
enforce-label:
name: EnforceLabel
runs-on: ubuntu-20.04
steps:
- name: Enforce Label
uses: yogevbd/enforce-label-action@8d1e1709b1011e6d90400a0e6cf7c0b77aa5efeb
with:
BANNED_LABELS: "hold"
BANNED_LABELS_DESCRIPTION: "PRs on hold cannot be merged"

203
apps/cli/.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,203 @@
---
name: Release
on:
workflow_dispatch:
inputs:
release_type:
description: 'Release Options'
required: true
default: 'Initial Release'
type: choice
options:
- Initial Release
- Redeploy
- Dry Run
jobs:
setup:
name: Setup
runs-on: ubuntu-20.04
outputs:
package_version: ${{ steps.retrieve-version.outputs.package_version }}
branch-name: ${{ steps.branch.outputs.branch-name }}
steps:
- name: Branch check
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
run: |
if [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix-rc" ]]; then
echo "==================================="
echo "[!] Can only release from the 'rc' or 'hotfix-rc' branches"
echo "==================================="
exit 1
fi
- name: Checkout repo
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # 2.4.0
- name: Retrieve CLI release version
id: retrieve-version
run: |
PKG_VERSION=$(jq -r .version package.json)
echo "::set-output name=package_version::$PKG_VERSION"
- name: Check to make sure CLI release version has been bumped
if: ${{ github.event.inputs.release_type == 'Initial Release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
latest_ver=$(hub release -L 1 -f '%T')
latest_ver=${latest_ver:1}
echo "Latest version: $latest_ver"
ver=${{ steps.retrieve-version.outputs.package_version }}
echo "Version: $ver"
if [ "$latest_ver" = "$ver" ]; then
echo "Version has not been bumped!"
exit 1
fi
shell: bash
- name: Get branch name
id: branch
run: |
BRANCH_NAME=$(basename ${{ github.ref }})
echo "::set-output name=branch-name::$BRANCH_NAME"
- name: Download all artifacts
uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8
with:
workflow: build.yml
workflow_conclusion: success
branch: ${{ steps.branch.outputs.branch-name }}
- name: Create release
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
uses: ncipollo/release-action@40bb172bd05f266cf9ba4ff965cb61e9ee5f6d01 # v1.9.0
env:
PKG_VERSION: ${{ steps.retrieve-version.outputs.package_version }}
with:
artifacts: "bw-windows-${{ env.PKG_VERSION }}.zip,
bw-windows-sha256-${{ env.PKG_VERSION }}.txt,
bw-macos-${{ env.PKG_VERSION }}.zip,
bw-macos-sha256-${{ env.PKG_VERSION }}.txt,
bw-linux-${{ env.PKG_VERSION }}.zip,
bw-linux-sha256-${{ env.PKG_VERSION }}.txt,
bitwarden-cli.${{ env.PKG_VERSION }}.nupkg,
bw_${{ env.PKG_VERSION }}_amd64.snap,
bw-snap-sha256-${{ env.PKG_VERSION }}.txt"
commit: ${{ github.sha }}
tag: v${{ env.PKG_VERSION }}
name: Version ${{ env.PKG_VERSION }}
body: "<insert release notes here>"
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
snap:
name: Deploy Snap
runs-on: ubuntu-20.04
needs: setup
env:
_PKG_VERSION: ${{ needs.setup.outputs.package_version }}
steps:
- name: Checkout repo
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # v2.4.0
- name: Login to Azure
uses: Azure/login@1f63701bf3e6892515f1b7ce2d2bf1708b46beaf
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Retrieve secrets
id: retrieve-secrets
uses: Azure/get-keyvault-secrets@b5c723b9ac7870c022b8c35befe620b7009b336f
with:
keyvault: "bitwarden-prod-kv"
secrets: "snapcraft-store-token"
- name: Install Snap
uses: samuelmeuli/action-snapcraft@10d7d0a84d9d86098b19f872257df314b0bd8e2d # v1.2.0
with:
snapcraft_token: ${{ steps.retrieve-secrets.outputs.snapcraft-store-token }}
- name: Download artifacts
uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8
with:
workflow: build.yml
workflow_conclusion: success
branch: ${{ needs.setup.outputs.branch-name }}
artifacts: bw_${{ env._PKG_VERSION }}_amd64.snap
- name: Publish Snap & logout
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
run: |
snapcraft push bw_${{ env._PKG_VERSION }}_amd64.snap --release stable
snapcraft logout
choco:
name: Deploy Choco
runs-on: windows-2019
needs: setup
env:
_PKG_VERSION: ${{ needs.setup.outputs.package_version }}
steps:
- name: Checkout repo
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # v2.4.0
- name: Setup Chocolatey
run: choco apikey --key $env:CHOCO_API_KEY --source https://push.chocolatey.org/
env:
CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }}
- name: Make dist dir
shell: pwsh
run: New-Item -ItemType directory -Path ./dist
- name: Download artifacts
uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8
with:
workflow: build.yml
workflow_conclusion: success
branch: ${{ needs.setup.outputs.branch-name }}
artifacts: bitwarden-cli.${{ env._PKG_VERSION }}.nupkg
path: ./dist
- name: Push to Chocolatey
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
shell: pwsh
run: |
cd dist
choco push
npm:
name: Publish NPM
runs-on: ubuntu-20.04
needs: setup
env:
_PKG_VERSION: ${{ needs.setup.outputs.package_version }}
steps:
- name: Checkout repo
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579 # v2.4.0
- name: Download artifacts
uses: bitwarden/gh-actions/download-artifacts@c1fa8e09871a860862d6bbe36184b06d2c7e35a8
with:
workflow: build.yml
workflow_conclusion: success
branch: ${{ needs.setup.outputs.branch-name }}
artifacts: bitwarden-cli-${{ env._PKG_VERSION }}-npm-build.zip
path: build
- name: Setup NPM
run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Install Husky
run: npm install -g husky
- name: Publish NPM
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
run: npm publish --access public

View File

@ -0,0 +1,71 @@
---
name: Version Bump
on:
workflow_dispatch:
inputs:
version_number:
description: "New Version"
required: true
jobs:
bump_version:
name: "Create version_bump_${{ github.event.inputs.version_number }} branch"
runs-on: ubuntu-20.04
steps:
- name: Checkout Branch
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
- name: Create Version Branch
run: |
git switch -c version_bump_${{ github.event.inputs.version_number }}
git push -u origin version_bump_${{ github.event.inputs.version_number }}
- name: Checkout Version Branch
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
with:
ref: version_bump_${{ github.event.inputs.version_number }}
- name: Bump Version - Package
uses: bitwarden/gh-actions/version-bump@03ad9a873c39cdc95dd8d77dbbda67f84db43945
with:
version: ${{ github.event.inputs.version_number }}
file_path: "./package.json"
- name: Bump Version - Package-lock
uses: bitwarden/gh-actions/version-bump@03ad9a873c39cdc95dd8d77dbbda67f84db43945
with:
version: ${{ github.event.inputs.version_number }}
file_path: "./package-lock.json"
- name: Commit files
run: |
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git commit -m "Bumped version to ${{ github.event.inputs.version_number }}" -a
- name: Push changes
run: git push -u origin version_bump_${{ github.event.inputs.version_number }}
- name: Create Version PR
env:
PR_BRANCH: "version_bump_${{ github.event.inputs.version_number }}"
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
BASE_BRANCH: master
TITLE: "Bump version to ${{ github.event.inputs.version_number }}"
run: |
gh pr create --title "$TITLE" \
--base "$BASE" \
--head "$PR_BRANCH" \
--label "version update" \
--label "automated pr" \
--body "
## Type of change
- [ ] Bug fix
- [ ] New feature development
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
- [ ] Build/deploy pipeline (DevOps)
- [X] Other
## Objective
Automated version bump to ${{ github.event.inputs.version_number }}"

View File

@ -0,0 +1,11 @@
---
name: Workflow Linter
on:
pull_request:
paths:
- .github/workflows/**
jobs:
call-workflow:
uses: bitwarden/gh-actions/.github/workflows/workflow-linter.yml@master

5
apps/cli/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
build
dist
config/local.json

3
apps/cli/.npmignore Normal file
View File

@ -0,0 +1,3 @@
*
!/build
!/build/**/*

25
apps/cli/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,25 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"protocol": "inspector",
"cwd": "${workspaceRoot}",
"program": "${workspaceFolder}/build/bw.js",
"env": {
"BW_SESSION": "fPZb0J+1NBzQ+HB512pLhSIIt2aRoOjqs6SrbxbTHVcsZdFk1cthzjBIMqBa2X7fjOOA3VU0bnR42fYeuWj2Vw=="
},
"sourceMapPathOverrides": {
"meteor://💻app/*": "${workspaceFolder}/*",
"webpack:///./~/*": "${workspaceFolder}/node_modules/*",
"webpack://?:*/*": "${workspaceFolder}/*",
"webpack://@bitwarden/cli/*": "${workspaceFolder}/*"
},
"smartStep": true,
"console": "integratedTerminal",
"args": ["login", "sdfsd@sdfdf.com", "ddddddd"]
}
]
}

10
apps/cli/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"debug.javascript.terminalOptions": {
"sourceMapPathOverrides": {
"meteor://💻app/*": "${workspaceFolder}/*",
"webpack:///./~/*": "${workspaceFolder}/node_modules/*",
"webpack://?:*/*": "${workspaceFolder}/*",
"webpack://@bitwarden/cli/*": "${workspaceFolder}/*"
}
}
}

84
apps/cli/README.md Normal file
View File

@ -0,0 +1,84 @@
[![Github Workflow build on master](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml/badge.svg?branch=master)](https://github.com/bitwarden/clients/actions/workflows/build-cli.yml?query=branch:master)
[![Join the chat at https://gitter.im/bitwarden/Lobby](https://badges.gitter.im/bitwarden/Lobby.svg)](https://gitter.im/bitwarden/Lobby)
# Bitwarden Command-line Interface
[![Platforms](https://imgur.com/AnTLX0S.png "Platforms")](https://help.bitwarden.com/article/cli/#download--install)
The Bitwarden CLI is a powerful, full-featured command-line interface (CLI) tool to access and manage a Bitwarden vault. The CLI is written with TypeScript and Node.js and can be run on Windows, macOS, and Linux distributions.
![CLI](https://raw.githubusercontent.com/bitwarden/brand/master/screenshots/cli-macos.png "CLI")
## Download/Install
You can install the Bitwarden CLI multiple different ways:
**NPM**
If you already have the Node.js runtime installed on your system, you can install the CLI using NPM. NPM makes it easy to keep your installation updated and should be the preferred installation method if you are already using Node.js.
```bash
npm install -g @bitwarden/cli
```
**Native Executable**
We provide natively packaged versions of the CLI for each platform which have no requirements on installing the Node.js runtime. You can obtain these from the [downloads section](https://help.bitwarden.com/article/cli/#download--install) in the documentation.
**Other Package Managers**
- [Chocolatey](https://chocolatey.org/packages/bitwarden-cli)
```powershell
choco install bitwarden-cli
```
- [Homebrew](https://formulae.brew.sh/formula/bitwarden-cli)
```bash
brew install bitwarden-cli
```
- [Snap](https://snapcraft.io/bw)
```bash
sudo snap install bw
```
## Documentation
The Bitwarden CLI is self-documented with `--help` content and examples for every command. You should start exploring the CLI by using the global `--help` option:
```bash
bw --help
```
This option will list all available commands that you can use with the CLI.
Additionally, you can run the `--help` option on a specific command to learn more about it:
```bash
bw list --help
bw create --help
```
**Detailed Documentation**
We provide detailed documentation and examples for using the CLI in our help center at https://help.bitwarden.com/article/cli/.
## Build/Run
**Requirements**
- [Node.js](https://nodejs.org) v16.13.1.
- Testing is done against Node 16, other versions may work, but are not guaranteed.
- NPM v8
**Run the app**
```bash
npm install
npm run sub:init # initialize the git submodule for jslib
npm run build:watch
```
You can then run commands from the `./build` folder:
```bash
node ./build/bw.js login
```

31
apps/cli/config/config.js Normal file
View File

@ -0,0 +1,31 @@
/* eslint-disable no-console */
function load(envName) {
return {
...loadConfig(envName),
...loadConfig("local"),
};
}
function log(configObj) {
const repeatNum = 50;
console.log(`${"=".repeat(repeatNum)}\nenvConfig`);
console.log(JSON.stringify(configObj, null, 2));
console.log(`${"=".repeat(repeatNum)}`);
}
function loadConfig(configName) {
try {
return require(`./${configName}.json`);
} catch (e) {
if (e instanceof Error && e.code === "MODULE_NOT_FOUND") {
return {};
} else {
throw e;
}
}
}
module.exports = {
load,
log,
};

View File

@ -0,0 +1,5 @@
{
"flags": {
"serve": true
}
}

View File

@ -0,0 +1,5 @@
{
"flags": {
"serve": true
}
}

View File

@ -0,0 +1,56 @@
#!/usr/bin/env bash
# bw git-credential helper
# Based on:
# * https://github.com/lastpass/lastpass-cli/blob/master/contrib/examples/git-credential-lastpass
# * https://gist.github.com/mikeboiko/58ab730afd65bca0a125bc12b6f4670d
# A credential helper for git to retrieve usernames and passwords from bw.
# For general usage, see https://git-scm.com/docs/gitcredentials.
# Here's a quick version:
# 1. Put this somewhere in your path.
# 2. git config --global credential.helper bw
declare -A params
if [[ "$1" == "get" ]]; then
read -r line
while [ -n "$line" ]; do
key=${line%%=*}
value=${line#*=}
params[$key]=$value
read -r line
done
if [[ "${params['protocol']}" != "https" ]]; then
exit
fi
if [[ -z "${params["host"]}" ]]; then
exit
fi
if ! bw list items --search "asdf" > /dev/null 2>&1; then
echo "Please login to Bitwarden to use git credential helper" > /dev/stderr
exit
fi
id=$(bw list items --search "${params["host"]}"|jq ".[] | select(.name == \"${params["host"]}\").id" -r)
if [[ -z "$id" ]]; then
echo "Couldn't find item id in Bitwarden DB." > /dev/stderr
echo "${params}"
exit
fi
user=$(bw get username "${id}")
pass=$(bw get password "${id}")
if [[ -z "$user" ]] || [[ -z "$pass" ]]; then
echo "Couldn't find host in Bitwarden DB." > /dev/stderr
exit
fi
echo username="$user"
echo password="$pass"
fi

1
apps/cli/jslib Submodule

@ -0,0 +1 @@
Subproject commit 1370006f6ea310cf85a12bcbd8213f74f9552c4d

10214
apps/cli/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

120
apps/cli/package.json Normal file
View File

@ -0,0 +1,120 @@
{
"name": "@bitwarden/cli",
"description": "A secure and free password manager for all of your devices.",
"version": "1.22.1",
"keywords": [
"bitwarden",
"password",
"vault",
"password manager",
"cli"
],
"author": "Bitwarden Inc. <hello@bitwarden.com> (https://bitwarden.com)",
"homepage": "https://bitwarden.com",
"repository": {
"type": "git",
"url": "https://github.com/bitwarden/cli"
},
"license": "GPL-3.0-only",
"scripts": {
"sub:init": "git submodule update --init --recursive",
"sub:update": "git submodule update --remote",
"sub:pull": "git submodule foreach git pull origin master",
"clean": "rimraf dist/**/*",
"symlink:win": "rmdir /S /Q ./jslib && cmd /c mklink /J .\\jslib ..\\jslib",
"symlink:mac": "npm run symlink:lin",
"symlink:lin": "rm -rf ./jslib && ln -s ../jslib ./jslib",
"build": "webpack",
"build:debug": "npm run build && node --inspect ./build/bw.js",
"build:watch": "webpack --watch",
"build:prod": "cross-env NODE_ENV=production webpack",
"build:prod:watch": "cross-env NODE_ENV=production webpack --watch",
"package": "npm run package:win && npm run package:mac && npm run package:lin",
"package:win": "pkg . --targets win-x64 --output ./dist/windows/bw.exe --build",
"package:mac": "pkg . --targets macos-x64 --output ./dist/macos/bw",
"package:lin": "pkg . --targets linux-x64 --output ./dist/linux/bw",
"debug": "node --inspect ./build/bw.js",
"dist": "npm run build:prod && npm run clean && npm run package",
"dist:win": "npm run build:prod && npm run clean && npm run package:win",
"dist:mac": "npm run build:prod && npm run clean && npm run package:mac",
"dist:lin": "npm run build:prod && npm run clean && npm run package:lin",
"publish:npm": "npm run build:prod && npm publish --access public",
"test": "jasmine-ts -r tsconfig-paths/register -P spec/tsconfig.json",
"test:watch": "nodemon -w ./spec -w ./src -w ./jslib --ext \"ts,js,mjs,json\" --exec jasmine-ts -r tsconfig-paths/register -P spec/tsconfig.json"
},
"bin": {
"bw": "build/bw.js"
},
"pkg": {
"assets": "./build/**/*"
},
"devDependencies": {
"@fluffy-spoon/substitute": "^1.208.0",
"@types/inquirer": "^7.3.1",
"@types/jasmine": "^3.7.0",
"@types/jsdom": "^16.2.10",
"@types/koa": "^2.13.4",
"@types/koa__multer": "^2.0.4",
"@types/koa__router": "^8.0.11",
"@types/koa-bodyparser": "^4.3.5",
"@types/koa-json": "^2.0.20",
"@types/lowdb": "^1.0.10",
"@types/lunr": "^2.3.3",
"@types/node": "^16.11.12",
"@types/node-fetch": "^2.5.10",
"@types/node-forge": "^1.0.1",
"@types/papaparse": "^5.2.5",
"@types/proper-lockfile": "^4.1.2",
"@types/retry": "^0.12.1",
"@types/tldjs": "^2.3.0",
"@types/zxcvbn": "^4.4.1",
"clean-webpack-plugin": "^4.0.0",
"copy-webpack-plugin": "^10.2.0",
"cross-env": "^7.0.3",
"jasmine": "^3.7.0",
"jasmine-core": "^3.7.1",
"jasmine-ts": "^0.4.0",
"jasmine-ts-console-reporter": "^3.1.1",
"pkg": "5.7.0",
"rimraf": "^3.0.2",
"ts-loader": "^8.2.0",
"ts-node": "^10.4.0",
"tsconfig-paths": "^3.12.0",
"tsconfig-paths-webpack-plugin": "^3.5.2",
"typescript": "4.1.5",
"webpack": "^5.65.0",
"webpack-cli": "^4.9.1",
"webpack-node-externals": "^3.0.0"
},
"dependencies": {
"@koa/multer": "^3.0.0",
"@koa/router": "^10.1.1",
"big-integer": "1.6.48",
"browser-hrtime": "^1.1.8",
"chalk": "^4.1.1",
"commander": "7.2.0",
"form-data": "4.0.0",
"https-proxy-agent": "5.0.0",
"inquirer": "8.0.0",
"jsdom": "^16.5.3",
"jszip": "^3.7.1",
"koa": "^2.13.4",
"koa-bodyparser": "^4.3.0",
"koa-json": "^2.0.2",
"lowdb": "1.0.0",
"lunr": "^2.3.9",
"multer": "^1.4.4",
"node-fetch": "^2.6.1",
"node-forge": "1.3.1",
"open": "^8.0.8",
"papaparse": "^5.3.0",
"proper-lockfile": "^4.1.2",
"rxjs": "6.6.7",
"tldjs": "^2.3.1",
"zxcvbn": "^4.4.2"
},
"engines": {
"node": "~16",
"npm": ">=7 <=8"
}
}

View File

@ -0,0 +1,23 @@
param (
[Parameter(Mandatory=$true)]
[string] $version
)
# Dependencies:
# 1. brew cask install powershell
# 2. Environment variables for HOMEBREW_GITHUB_USER and HOMEBREW_GITHUB_API_TOKEN set.
#
# To run:
# pwsh ./brew-update.ps1 -version 1.1.0
# Cleaning up
cd $("$(brew --repository)" + "/Library/Taps/homebrew/homebrew-core/Formula")
git checkout master
git reset --hard origin/master
git push $env:HOMEBREW_GITHUB_USER master
git branch -D $("bitwarden-cli-" + $version)
git push $env:HOMEBREW_GITHUB_USER --delete $("bitwarden-cli-" + $version)
# Bump
$url = 'https://registry.npmjs.org/@bitwarden/cli/-/cli-' + $version + '.tgz';
brew bump-formula-pr --url="$url" bitwarden-cli

View File

@ -0,0 +1,34 @@
param (
[switch] $push
)
# To run:
# .\choco-pack.ps1
$dir = Split-Path -Parent $MyInvocation.MyCommand.Path;
$rootDir = $dir + "\..";
$distDir = $rootDir + "\dist";
$chocoDir = $rootDir + "\stores\chocolatey";
$distChocoDir = $distDir + "\chocolatey";
$distChocoToolsDir = $distDir + "\chocolatey\tools";
if(Test-Path -Path $distChocoDir) {
Remove-Item -Recurse -Force $distChocoDir
}
$exe = $distDir + "\windows\bw.exe";
$license = $rootDir + "\LICENSE.txt";
Copy-Item -Path $chocoDir -Destination $distChocoDir Recurse
Copy-Item $exe -Destination $distChocoToolsDir;
Copy-Item $license -Destination $distChocoToolsDir;
$srcPackage = $rootDir + "\package.json";
$srcPackageVersion = (Get-Content -Raw -Path $srcPackage | ConvertFrom-Json).version;
$nuspec = $distChocoDir + "\bitwarden-cli.nuspec";
choco pack $nuspec --version $srcPackageVersion --out $distChocoDir
if ($push) {
cd $distChocoDir
choco push
cd $rootDir
}

View File

@ -0,0 +1,26 @@
param (
[Parameter(Mandatory=$true)]
[string] $version
)
# To run:
# .\choco-update.ps1 -version 1.3.0
$dir = Split-Path -Parent $MyInvocation.MyCommand.Path;
$rootDir = $dir + "\..";
$distDir = $rootDir + "\dist";
$distChocoDir = $distDir + "\chocolatey";
if(Test-Path -Path $distChocoDir) {
Remove-Item -Recurse -Force $distChocoDir
}
New-Item -ItemType directory -Path $distChocoDir | Out-Null
$nupkg = "bitwarden-cli." + $version + ".nupkg"
$uri = "https://github.com/bitwarden/cli/releases/download/v" + $version + "/" + $nupkg;
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-RestMethod -Uri $uri -OutFile $($distChocoDir + "\" + $nupkg)
cd $distChocoDir
choco push
cd $rootDir

View File

@ -0,0 +1,32 @@
param (
[Parameter(Mandatory=$true)]
[string] $version
)
# Dependencies:
# 1. Install powershell, ex `sudo apt-get install -y powershell`
#
# To run:
# ./snap-build.ps1 -version 1.1.0
#
# and then push to snap with:
# cd ../dist/snap
# snap push bw*.snap
# or, use the ./snap-update.ps1 script
$dir = Split-Path -Parent $MyInvocation.MyCommand.Path
$rootDir = $dir + "/.."
$distDir = $rootDir + "/dist"
$snapDir = $rootDir + "/stores/snap"
$distSnapDir = $distDir + "/snap"
$snapDistYaml = $distSnapDir + "/snapcraft.yaml"
if(Test-Path -Path $distSnapDir) {
Remove-Item -Recurse -Force $distSnapDir
}
Copy-Item -Path $snapDir -Destination $distSnapDir Recurse
(Get-Content $snapDistYaml).replace('__version__', $version) | Set-Content $snapDistYaml
cd $distSnapDir
snapcraft
cd $rootDir

View File

@ -0,0 +1,12 @@
# Dependencies:
# 1. Install powershell, ex `sudo apt-get install -y powershell`
#
# To run:
# pwsh ./snap-update.ps1
$dir = Split-Path -Parent $MyInvocation.MyCommand.Path;
$rootDir = $dir + "/..";
$distDir = $rootDir + "/dist";
$distSnap = $distDir + "/snap/bw*.snap";
snapcraft push $distSnap --release stable

3
apps/cli/spec/bw.spec.ts Normal file
View File

@ -0,0 +1,3 @@
describe("bw", () => {
it("is a placeholder test");
});

4
apps/cli/spec/helpers.ts Normal file
View File

@ -0,0 +1,4 @@
// eslint-disable-next-line
const TSConsoleReporter = require("jasmine-ts-console-reporter");
jasmine.getEnv().clearReporters(); // Clear default console reporter
jasmine.getEnv().addReporter(new TSConsoleReporter());

View File

@ -0,0 +1,7 @@
{
"spec_dir": "spec",
"spec_files": ["**/*[sS]pec.ts"],
"helpers": ["helpers.ts"],
"stopSpecOnExpectationFailure": false,
"random": true
}

View File

@ -0,0 +1,7 @@
{
"extends": "../tsconfig",
"include": ["src", "spec"],
"compilerOptions": {
"module": "commonjs"
}
}

View File

@ -0,0 +1,27 @@
import { FlagName } from "../src/flags";
import { CliUtils } from "../src/utils";
describe("flagEnabled", () => {
it("is true if flag is null", () => {
process.env.FLAGS = JSON.stringify({ test: null });
expect(CliUtils.flagEnabled("test" as FlagName)).toBe(true);
});
it("is true if flag is undefined", () => {
process.env.FLAGS = JSON.stringify({});
expect(CliUtils.flagEnabled("test" as FlagName)).toBe(true);
});
it("is true if flag is true", () => {
process.env.FLAGS = JSON.stringify({ test: true });
expect(CliUtils.flagEnabled("test" as FlagName)).toBe(true);
});
it("is false if flag is false", () => {
process.env.FLAGS = JSON.stringify({ test: false });
expect(CliUtils.flagEnabled("test" as FlagName)).toBe(false);
});
});

380
apps/cli/src/bw.ts Normal file
View File

@ -0,0 +1,380 @@
import * as fs from "fs";
import * as path from "path";
import * as program from "commander";
import * as jsdom from "jsdom";
import { ClientType } from "jslib-common/enums/clientType";
import { KeySuffixOptions } from "jslib-common/enums/keySuffixOptions";
import { LogLevelType } from "jslib-common/enums/logLevelType";
import { StateFactory } from "jslib-common/factories/stateFactory";
import { Account } from "jslib-common/models/domain/account";
import { GlobalState } from "jslib-common/models/domain/globalState";
import { AppIdService } from "jslib-common/services/appId.service";
import { AuditService } from "jslib-common/services/audit.service";
import { AuthService } from "jslib-common/services/auth.service";
import { CipherService } from "jslib-common/services/cipher.service";
import { CollectionService } from "jslib-common/services/collection.service";
import { ContainerService } from "jslib-common/services/container.service";
import { CryptoService } from "jslib-common/services/crypto.service";
import { EnvironmentService } from "jslib-common/services/environment.service";
import { ExportService } from "jslib-common/services/export.service";
import { FileUploadService } from "jslib-common/services/fileUpload.service";
import { FolderService } from "jslib-common/services/folder.service";
import { ImportService } from "jslib-common/services/import.service";
import { KeyConnectorService } from "jslib-common/services/keyConnector.service";
import { NoopMessagingService } from "jslib-common/services/noopMessaging.service";
import { OrganizationService } from "jslib-common/services/organization.service";
import { PasswordGenerationService } from "jslib-common/services/passwordGeneration.service";
import { PolicyService } from "jslib-common/services/policy.service";
import { ProviderService } from "jslib-common/services/provider.service";
import { SearchService } from "jslib-common/services/search.service";
import { SendService } from "jslib-common/services/send.service";
import { SettingsService } from "jslib-common/services/settings.service";
import { StateService } from "jslib-common/services/state.service";
import { StateMigrationService } from "jslib-common/services/stateMigration.service";
import { SyncService } from "jslib-common/services/sync.service";
import { TokenService } from "jslib-common/services/token.service";
import { TotpService } from "jslib-common/services/totp.service";
import { TwoFactorService } from "jslib-common/services/twoFactor.service";
import { UserVerificationService } from "jslib-common/services/userVerification.service";
import { VaultTimeoutService } from "jslib-common/services/vaultTimeout.service";
import { CliPlatformUtilsService } from "jslib-node/cli/services/cliPlatformUtils.service";
import { ConsoleLogService } from "jslib-node/cli/services/consoleLog.service";
import { NodeApiService } from "jslib-node/services/nodeApi.service";
import { NodeCryptoFunctionService } from "jslib-node/services/nodeCryptoFunction.service";
import { Program } from "./program";
import { SendProgram } from "./send.program";
import { I18nService } from "./services/i18n.service";
import { LowdbStorageService } from "./services/lowdbStorage.service";
import { NodeEnvSecureStorageService } from "./services/nodeEnvSecureStorage.service";
import { VaultProgram } from "./vault.program";
// Polyfills
(global as any).DOMParser = new jsdom.JSDOM().window.DOMParser;
// eslint-disable-next-line
const packageJson = require("../package.json");
export class Main {
messagingService: NoopMessagingService;
storageService: LowdbStorageService;
secureStorageService: NodeEnvSecureStorageService;
i18nService: I18nService;
platformUtilsService: CliPlatformUtilsService;
cryptoService: CryptoService;
tokenService: TokenService;
appIdService: AppIdService;
apiService: NodeApiService;
environmentService: EnvironmentService;
settingsService: SettingsService;
cipherService: CipherService;
folderService: FolderService;
collectionService: CollectionService;
vaultTimeoutService: VaultTimeoutService;
syncService: SyncService;
passwordGenerationService: PasswordGenerationService;
totpService: TotpService;
containerService: ContainerService;
auditService: AuditService;
importService: ImportService;
exportService: ExportService;
searchService: SearchService;
cryptoFunctionService: NodeCryptoFunctionService;
authService: AuthService;
policyService: PolicyService;
program: Program;
vaultProgram: VaultProgram;
sendProgram: SendProgram;
logService: ConsoleLogService;
sendService: SendService;
fileUploadService: FileUploadService;
keyConnectorService: KeyConnectorService;
userVerificationService: UserVerificationService;
stateService: StateService;
stateMigrationService: StateMigrationService;
organizationService: OrganizationService;
providerService: ProviderService;
twoFactorService: TwoFactorService;
constructor() {
let p = null;
const relativeDataDir = path.join(path.dirname(process.execPath), "bw-data");
if (fs.existsSync(relativeDataDir)) {
p = relativeDataDir;
} else if (process.env.BITWARDENCLI_APPDATA_DIR) {
p = path.resolve(process.env.BITWARDENCLI_APPDATA_DIR);
} else if (process.platform === "darwin") {
p = path.join(process.env.HOME, "Library/Application Support/Bitwarden CLI");
} else if (process.platform === "win32") {
p = path.join(process.env.APPDATA, "Bitwarden CLI");
} else if (process.env.XDG_CONFIG_HOME) {
p = path.join(process.env.XDG_CONFIG_HOME, "Bitwarden CLI");
} else {
p = path.join(process.env.HOME, ".config/Bitwarden CLI");
}
this.i18nService = new I18nService("en", "./locales");
this.platformUtilsService = new CliPlatformUtilsService(ClientType.Cli, packageJson);
this.logService = new ConsoleLogService(
this.platformUtilsService.isDev(),
(level) => process.env.BITWARDENCLI_DEBUG !== "true" && level <= LogLevelType.Info
);
this.cryptoFunctionService = new NodeCryptoFunctionService();
this.storageService = new LowdbStorageService(this.logService, null, p, false, true);
this.secureStorageService = new NodeEnvSecureStorageService(
this.storageService,
this.logService,
() => this.cryptoService
);
this.stateMigrationService = new StateMigrationService(
this.storageService,
this.secureStorageService,
new StateFactory(GlobalState, Account)
);
this.stateService = new StateService(
this.storageService,
this.secureStorageService,
this.logService,
this.stateMigrationService,
new StateFactory(GlobalState, Account)
);
this.cryptoService = new CryptoService(
this.cryptoFunctionService,
this.platformUtilsService,
this.logService,
this.stateService
);
this.appIdService = new AppIdService(this.storageService);
this.tokenService = new TokenService(this.stateService);
this.messagingService = new NoopMessagingService();
this.environmentService = new EnvironmentService(this.stateService);
const customUserAgent =
"Bitwarden_CLI/" +
this.platformUtilsService.getApplicationVersionSync() +
" (" +
this.platformUtilsService.getDeviceString().toUpperCase() +
")";
this.apiService = new NodeApiService(
this.tokenService,
this.platformUtilsService,
this.environmentService,
this.appIdService,
async (expired: boolean) => await this.logout(),
customUserAgent
);
this.containerService = new ContainerService(this.cryptoService);
this.settingsService = new SettingsService(this.stateService);
this.fileUploadService = new FileUploadService(this.logService, this.apiService);
this.cipherService = new CipherService(
this.cryptoService,
this.settingsService,
this.apiService,
this.fileUploadService,
this.i18nService,
null,
this.logService,
this.stateService
);
this.folderService = new FolderService(
this.cryptoService,
this.apiService,
this.i18nService,
this.cipherService,
this.stateService
);
this.collectionService = new CollectionService(
this.cryptoService,
this.i18nService,
this.stateService
);
this.searchService = new SearchService(this.cipherService, this.logService, this.i18nService);
this.providerService = new ProviderService(this.stateService);
this.organizationService = new OrganizationService(this.stateService);
this.policyService = new PolicyService(
this.stateService,
this.organizationService,
this.apiService
);
this.sendService = new SendService(
this.cryptoService,
this.apiService,
this.fileUploadService,
this.i18nService,
this.cryptoFunctionService,
this.stateService
);
this.keyConnectorService = new KeyConnectorService(
this.stateService,
this.cryptoService,
this.apiService,
this.tokenService,
this.logService,
this.organizationService,
this.cryptoFunctionService
);
this.twoFactorService = new TwoFactorService(this.i18nService, this.platformUtilsService);
this.authService = new AuthService(
this.cryptoService,
this.apiService,
this.tokenService,
this.appIdService,
this.platformUtilsService,
this.messagingService,
this.logService,
this.keyConnectorService,
this.environmentService,
this.stateService,
this.twoFactorService,
this.i18nService
);
const lockedCallback = async () =>
await this.cryptoService.clearStoredKey(KeySuffixOptions.Auto);
this.vaultTimeoutService = new VaultTimeoutService(
this.cipherService,
this.folderService,
this.collectionService,
this.cryptoService,
this.platformUtilsService,
this.messagingService,
this.searchService,
this.tokenService,
this.policyService,
this.keyConnectorService,
this.stateService,
this.authService,
lockedCallback,
null
);
this.syncService = new SyncService(
this.apiService,
this.settingsService,
this.folderService,
this.cipherService,
this.cryptoService,
this.collectionService,
this.messagingService,
this.policyService,
this.sendService,
this.logService,
this.keyConnectorService,
this.stateService,
this.organizationService,
this.providerService,
async (expired: boolean) => await this.logout()
);
this.passwordGenerationService = new PasswordGenerationService(
this.cryptoService,
this.policyService,
this.stateService
);
this.totpService = new TotpService(
this.cryptoFunctionService,
this.logService,
this.stateService
);
this.importService = new ImportService(
this.cipherService,
this.folderService,
this.apiService,
this.i18nService,
this.collectionService,
this.platformUtilsService,
this.cryptoService
);
this.exportService = new ExportService(
this.folderService,
this.cipherService,
this.apiService,
this.cryptoService,
this.cryptoFunctionService
);
this.auditService = new AuditService(this.cryptoFunctionService, this.apiService);
this.program = new Program(this);
this.vaultProgram = new VaultProgram(this);
this.sendProgram = new SendProgram(this);
this.userVerificationService = new UserVerificationService(
this.cryptoService,
this.i18nService,
this.apiService
);
}
async run() {
await this.init();
await this.program.register();
await this.vaultProgram.register();
await this.sendProgram.register();
program.parse(process.argv);
if (process.argv.slice(2).length === 0) {
program.outputHelp();
}
}
async logout() {
this.authService.logOut(() => {
/* Do nothing */
});
const userId = await this.stateService.getUserId();
await Promise.all([
this.syncService.setLastSync(new Date(0)),
this.cryptoService.clearKeys(),
this.settingsService.clear(userId),
this.cipherService.clear(userId),
this.folderService.clear(userId),
this.collectionService.clear(userId),
this.policyService.clear(userId),
this.passwordGenerationService.clear(),
]);
await this.stateService.clean();
process.env.BW_SESSION = null;
}
private async init() {
await this.storageService.init();
await this.stateService.init();
this.containerService.attachToWindow(global);
await this.environmentService.setUrlsFromStorage();
const locale = await this.stateService.getLocale();
await this.i18nService.init(locale);
this.twoFactorService.init();
const installedVersion = await this.stateService.getInstalledVersion();
const currentVersion = await this.platformUtilsService.getApplicationVersion();
if (installedVersion == null || installedVersion !== currentVersion) {
await this.stateService.setInstalledVersion(currentVersion);
}
}
}
const main = new Main();
main.run();

View File

@ -0,0 +1,119 @@
import * as program from "commander";
import { Response } from "jslib-node/cli/models/response";
import { MessageResponse } from "jslib-node/cli/models/response/messageResponse";
interface IOption {
long?: string;
short?: string;
description: string;
}
interface ICommand {
commands?: ICommand[];
options?: IOption[];
_name: string;
_description: string;
}
const validShells = ["zsh"];
export class CompletionCommand {
async run(options: program.OptionValues) {
const shell: typeof validShells[number] = options.shell;
if (!shell) {
return Response.badRequest("`shell` option was not provided.");
}
if (!validShells.includes(shell)) {
return Response.badRequest("Unsupported shell.");
}
let content = "";
if (shell === "zsh") {
content = this.zshCompletion("bw", program as any as ICommand).render();
}
const res = new MessageResponse(content, null);
return Response.success(res);
}
private zshCompletion(rootName: string, rootCommand: ICommand) {
return {
render: () => {
return [
`#compdef _${rootName} ${rootName}`,
"",
this.renderCommandBlock(rootName, rootCommand),
].join("\n");
},
};
}
private renderCommandBlock(name: string, command: ICommand): string {
const { commands = [], options = [] } = command;
const hasOptions = options.length > 0;
const hasCommands = commands.length > 0;
const args = options
.map(({ long, short, description }) => {
const aliases = [short, long].filter(Boolean);
const opts = aliases.join(",");
const desc = `[${description.replace(`'`, `'"'"'`)}]`;
return aliases.length > 1
? `'(${aliases.join(" ")})'{${opts}}'${desc}'`
: `'${opts}${desc}'`;
})
.concat(
`'(-h --help)'{-h,--help}'[output usage information]'`,
hasCommands ? '"1: :->cmnds"' : null,
'"*::arg:->args"'
)
.filter(Boolean);
const commandBlockFunctionParts = [];
if (hasCommands) {
commandBlockFunctionParts.push("local -a commands");
}
if (hasOptions) {
commandBlockFunctionParts.push(`_arguments -C \\\n ${args.join(` \\\n `)}`);
}
if (hasCommands) {
commandBlockFunctionParts.push(
`case $state in
cmnds)
commands=(
${commands
.map(({ _name, _description }) => `"${_name}:${_description}"`)
.join("\n ")}
)
_describe "command" commands
;;
esac
case "$words[1]" in
${commands
.map(({ _name }) => [`${_name})`, `_${name}_${_name}`, ";;"].join("\n "))
.join("\n ")}
esac`
);
}
const commandBlocParts = [
`function _${name} {\n ${commandBlockFunctionParts.join("\n\n ")}\n}`,
];
if (hasCommands) {
commandBlocParts.push(
commands.map((c) => this.renderCommandBlock(`${name}_${c._name}`, c)).join("\n\n")
);
}
return commandBlocParts.join("\n\n");
}
}

View File

@ -0,0 +1,53 @@
import * as program from "commander";
import { EnvironmentService } from "jslib-common/abstractions/environment.service";
import { Response } from "jslib-node/cli/models/response";
import { MessageResponse } from "jslib-node/cli/models/response/messageResponse";
import { StringResponse } from "jslib-node/cli/models/response/stringResponse";
export class ConfigCommand {
constructor(private environmentService: EnvironmentService) {}
async run(setting: string, value: string, options: program.OptionValues): Promise<Response> {
setting = setting.toLowerCase();
switch (setting) {
case "server":
return await this.getOrSetServer(value, options);
default:
return Response.badRequest("Unknown setting.");
}
}
private async getOrSetServer(url: string, options: program.OptionValues): Promise<Response> {
if (
(url == null || url.trim() === "") &&
!options.webVault &&
!options.api &&
!options.identity &&
!options.icons &&
!options.notifications &&
!options.events
) {
const stringRes = new StringResponse(
this.environmentService.hasBaseUrl()
? this.environmentService.getUrls().base
: "https://bitwarden.com"
);
return Response.success(stringRes);
}
url = url === "null" || url === "bitwarden.com" || url === "https://bitwarden.com" ? null : url;
await this.environmentService.setUrls({
base: url,
webVault: options.webVault || null,
api: options.api || null,
identity: options.identity || null,
icons: options.icons || null,
notifications: options.notifications || null,
events: options.events || null,
keyConnector: options.keyConnector || null,
});
const res = new MessageResponse("Saved setting `config`.", null);
return Response.success(res);
}
}

View File

@ -0,0 +1,62 @@
import { ApiService } from "jslib-common/abstractions/api.service";
import { CryptoService } from "jslib-common/abstractions/crypto.service";
import { Utils } from "jslib-common/misc/utils";
import { OrganizationUserConfirmRequest } from "jslib-common/models/request/organizationUserConfirmRequest";
import { Response } from "jslib-node/cli/models/response";
export class ConfirmCommand {
constructor(private apiService: ApiService, private cryptoService: CryptoService) {}
async run(object: string, id: string, cmdOptions: Record<string, any>): Promise<Response> {
if (id != null) {
id = id.toLowerCase();
}
const normalizedOptions = new Options(cmdOptions);
switch (object.toLowerCase()) {
case "org-member":
return await this.confirmOrganizationMember(id, normalizedOptions);
default:
return Response.badRequest("Unknown object.");
}
}
private async confirmOrganizationMember(id: string, options: Options) {
if (options.organizationId == null || options.organizationId === "") {
return Response.badRequest("--organizationid <organizationid> required.");
}
if (!Utils.isGuid(id)) {
return Response.badRequest("`" + id + "` is not a GUID.");
}
if (!Utils.isGuid(options.organizationId)) {
return Response.badRequest("`" + options.organizationId + "` is not a GUID.");
}
try {
const orgKey = await this.cryptoService.getOrgKey(options.organizationId);
if (orgKey == null) {
throw new Error("No encryption key for this organization.");
}
const orgUser = await this.apiService.getOrganizationUser(options.organizationId, id);
if (orgUser == null) {
throw new Error("Member id does not exist for this organization.");
}
const publicKeyResponse = await this.apiService.getUserPublicKey(orgUser.userId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
const key = await this.cryptoService.rsaEncrypt(orgKey.key, publicKey.buffer);
const req = new OrganizationUserConfirmRequest();
req.key = key.encryptedString;
await this.apiService.postOrganizationUserConfirm(options.organizationId, id, req);
return Response.success();
} catch (e) {
return Response.error(e);
}
}
}
class Options {
organizationId: string;
constructor(passedOptions: Record<string, any>) {
this.organizationId = passedOptions?.organizationid || passedOptions?.organizationId;
}
}

View File

@ -0,0 +1,84 @@
import * as inquirer from "inquirer";
import { ApiService } from "jslib-common/abstractions/api.service";
import { EnvironmentService } from "jslib-common/abstractions/environment.service";
import { KeyConnectorService } from "jslib-common/abstractions/keyConnector.service";
import { SyncService } from "jslib-common/abstractions/sync.service";
import { Response } from "jslib-node/cli/models/response";
import { MessageResponse } from "jslib-node/cli/models/response/messageResponse";
export class ConvertToKeyConnectorCommand {
constructor(
private apiService: ApiService,
private keyConnectorService: KeyConnectorService,
private environmentService: EnvironmentService,
private syncService: SyncService,
private logout: () => Promise<void>
) {}
async run(): Promise<Response> {
// If no interaction available, alert user to use web vault
const canInteract = process.env.BW_NOINTERACTION !== "true";
if (!canInteract) {
await this.logout();
return Response.error(
new MessageResponse(
"An organization you are a member of is using Key Connector. " +
"In order to access the vault, you must opt-in to Key Connector now via the web vault. You have been logged out.",
null
)
);
}
const organization = await this.keyConnectorService.getManagingOrganization();
const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({
type: "list",
name: "convert",
message:
organization.name +
" is using a self-hosted key server. A master password is no longer required to log in for members of this organization. ",
choices: [
{
name: "Remove master password and unlock",
value: "remove",
},
{
name: "Leave organization and unlock",
value: "leave",
},
{
name: "Log out",
value: "exit",
},
],
});
if (answer.convert === "remove") {
try {
await this.keyConnectorService.migrateUser();
} catch (e) {
await this.logout();
throw e;
}
await this.keyConnectorService.removeConvertAccountRequired();
await this.keyConnectorService.setUsesKeyConnector(true);
// Update environment URL - required for api key login
const urls = this.environmentService.getUrls();
urls.keyConnector = organization.keyConnectorUrl;
await this.environmentService.setUrls(urls);
return Response.success();
} else if (answer.convert === "leave") {
await this.apiService.postLeaveOrganization(organization.id);
await this.keyConnectorService.removeConvertAccountRequired();
await this.syncService.fullSync(true);
return Response.success();
} else {
await this.logout();
return Response.error("You have been logged out.");
}
}
}

View File

@ -0,0 +1,206 @@
import * as fs from "fs";
import * as path from "path";
import { ApiService } from "jslib-common/abstractions/api.service";
import { CipherService } from "jslib-common/abstractions/cipher.service";
import { CryptoService } from "jslib-common/abstractions/crypto.service";
import { FolderService } from "jslib-common/abstractions/folder.service";
import { StateService } from "jslib-common/abstractions/state.service";
import { Utils } from "jslib-common/misc/utils";
import { CipherExport } from "jslib-common/models/export/cipherExport";
import { CollectionExport } from "jslib-common/models/export/collectionExport";
import { FolderExport } from "jslib-common/models/export/folderExport";
import { CollectionRequest } from "jslib-common/models/request/collectionRequest";
import { SelectionReadOnlyRequest } from "jslib-common/models/request/selectionReadOnlyRequest";
import { Response } from "jslib-node/cli/models/response";
import { OrganizationCollectionRequest } from "../models/request/organizationCollectionRequest";
import { CipherResponse } from "../models/response/cipherResponse";
import { FolderResponse } from "../models/response/folderResponse";
import { OrganizationCollectionResponse } from "../models/response/organizationCollectionResponse";
import { CliUtils } from "../utils";
export class CreateCommand {
constructor(
private cipherService: CipherService,
private folderService: FolderService,
private stateService: StateService,
private cryptoService: CryptoService,
private apiService: ApiService
) {}
async run(
object: string,
requestJson: string,
cmdOptions: Record<string, any>,
additionalData: any = null
): Promise<Response> {
let req: any = null;
if (object !== "attachment") {
if (process.env.BW_SERVE !== "true" && (requestJson == null || requestJson === "")) {
requestJson = await CliUtils.readStdin();
}
if (requestJson == null || requestJson === "") {
return Response.badRequest("`requestJson` was not provided.");
}
if (typeof requestJson !== "string") {
req = requestJson;
} else {
try {
const reqJson = Buffer.from(requestJson, "base64").toString();
req = JSON.parse(reqJson);
} catch (e) {
return Response.badRequest("Error parsing the encoded request data.");
}
}
}
const normalizedOptions = new Options(cmdOptions);
switch (object.toLowerCase()) {
case "item":
return await this.createCipher(req);
case "attachment":
return await this.createAttachment(normalizedOptions, additionalData);
case "folder":
return await this.createFolder(req);
case "org-collection":
return await this.createOrganizationCollection(req, normalizedOptions);
default:
return Response.badRequest("Unknown object.");
}
}
private async createCipher(req: CipherExport) {
const cipher = await this.cipherService.encrypt(CipherExport.toView(req));
try {
await this.cipherService.saveWithServer(cipher);
const newCipher = await this.cipherService.get(cipher.id);
const decCipher = await newCipher.decrypt();
const res = new CipherResponse(decCipher);
return Response.success(res);
} catch (e) {
return Response.error(e);
}
}
private async createAttachment(options: Options, additionalData: any) {
if (options.itemId == null || options.itemId === "") {
return Response.badRequest("`itemid` option is required.");
}
let fileBuf: Buffer = null;
let fileName: string = null;
if (process.env.BW_SERVE === "true") {
fileBuf = additionalData.fileBuffer;
fileName = additionalData.fileName;
} else {
if (options.file == null || options.file === "") {
return Response.badRequest("`file` option is required.");
}
const filePath = path.resolve(options.file);
if (!fs.existsSync(options.file)) {
return Response.badRequest("Cannot find file at " + filePath);
}
fileBuf = fs.readFileSync(filePath);
fileName = path.basename(filePath);
}
if (fileBuf == null) {
return Response.badRequest("File not provided.");
}
if (fileName == null || fileName.trim() === "") {
return Response.badRequest("File name not provided.");
}
const itemId = options.itemId.toLowerCase();
const cipher = await this.cipherService.get(itemId);
if (cipher == null) {
return Response.notFound();
}
if (cipher.organizationId == null && !(await this.stateService.getCanAccessPremium())) {
return Response.error("Premium status is required to use this feature.");
}
const encKey = await this.cryptoService.getEncKey();
if (encKey == null) {
return Response.error(
"You must update your encryption key before you can use this feature. " +
"See https://help.bitwarden.com/article/update-encryption-key/"
);
}
try {
await this.cipherService.saveAttachmentRawWithServer(
cipher,
fileName,
new Uint8Array(fileBuf).buffer
);
const updatedCipher = await this.cipherService.get(cipher.id);
const decCipher = await updatedCipher.decrypt();
return Response.success(new CipherResponse(decCipher));
} catch (e) {
return Response.error(e);
}
}
private async createFolder(req: FolderExport) {
const folder = await this.folderService.encrypt(FolderExport.toView(req));
try {
await this.folderService.saveWithServer(folder);
const newFolder = await this.folderService.get(folder.id);
const decFolder = await newFolder.decrypt();
const res = new FolderResponse(decFolder);
return Response.success(res);
} catch (e) {
return Response.error(e);
}
}
private async createOrganizationCollection(req: OrganizationCollectionRequest, options: Options) {
if (options.organizationId == null || options.organizationId === "") {
return Response.badRequest("`organizationid` option is required.");
}
if (!Utils.isGuid(options.organizationId)) {
return Response.badRequest("`" + options.organizationId + "` is not a GUID.");
}
if (options.organizationId !== req.organizationId) {
return Response.badRequest("`organizationid` option does not match request object.");
}
try {
const orgKey = await this.cryptoService.getOrgKey(req.organizationId);
if (orgKey == null) {
throw new Error("No encryption key for this organization.");
}
const groups =
req.groups == null
? null
: req.groups.map((g) => new SelectionReadOnlyRequest(g.id, g.readOnly, g.hidePasswords));
const request = new CollectionRequest();
request.name = (await this.cryptoService.encrypt(req.name, orgKey)).encryptedString;
request.externalId = req.externalId;
request.groups = groups;
const response = await this.apiService.postCollection(req.organizationId, request);
const view = CollectionExport.toView(req);
view.id = response.id;
const res = new OrganizationCollectionResponse(view, groups);
return Response.success(res);
} catch (e) {
return Response.error(e);
}
}
}
class Options {
itemId: string;
organizationId: string;
file: string;
constructor(passedOptions: Record<string, any>) {
this.organizationId = passedOptions?.organizationid || passedOptions?.organizationId;
this.itemId = passedOptions?.itemid || passedOptions?.itemId;
this.file = passedOptions?.file;
}
}

View File

@ -0,0 +1,131 @@
import { ApiService } from "jslib-common/abstractions/api.service";
import { CipherService } from "jslib-common/abstractions/cipher.service";
import { FolderService } from "jslib-common/abstractions/folder.service";
import { StateService } from "jslib-common/abstractions/state.service";
import { Utils } from "jslib-common/misc/utils";
import { Response } from "jslib-node/cli/models/response";
import { CliUtils } from "src/utils";
export class DeleteCommand {
constructor(
private cipherService: CipherService,
private folderService: FolderService,
private stateService: StateService,
private apiService: ApiService
) {}
async run(object: string, id: string, cmdOptions: Record<string, any>): Promise<Response> {
if (id != null) {
id = id.toLowerCase();
}
const normalizedOptions = new Options(cmdOptions);
switch (object.toLowerCase()) {
case "item":
return await this.deleteCipher(id, normalizedOptions);
case "attachment":
return await this.deleteAttachment(id, normalizedOptions);
case "folder":
return await this.deleteFolder(id);
case "org-collection":
return await this.deleteOrganizationCollection(id, normalizedOptions);
default:
return Response.badRequest("Unknown object.");
}
}
private async deleteCipher(id: string, options: Options) {
const cipher = await this.cipherService.get(id);
if (cipher == null) {
return Response.notFound();
}
try {
if (options.permanent) {
await this.cipherService.deleteWithServer(id);
} else {
await this.cipherService.softDeleteWithServer(id);
}
return Response.success();
} catch (e) {
return Response.error(e);
}
}
private async deleteAttachment(id: string, options: Options) {
if (options.itemId == null || options.itemId === "") {
return Response.badRequest("`itemid` option is required.");
}
const itemId = options.itemId.toLowerCase();
const cipher = await this.cipherService.get(itemId);
if (cipher == null) {
return Response.notFound();
}
if (cipher.attachments == null || cipher.attachments.length === 0) {
return Response.error("No attachments available for this item.");
}
const attachments = cipher.attachments.filter((a) => a.id.toLowerCase() === id);
if (attachments.length === 0) {
return Response.error("Attachment `" + id + "` was not found.");
}
if (cipher.organizationId == null && !(await this.stateService.getCanAccessPremium())) {
return Response.error("Premium status is required to use this feature.");
}
try {
await this.cipherService.deleteAttachmentWithServer(cipher.id, attachments[0].id);
return Response.success();
} catch (e) {
return Response.error(e);
}
}
private async deleteFolder(id: string) {
const folder = await this.folderService.get(id);
if (folder == null) {
return Response.notFound();
}
try {
await this.folderService.deleteWithServer(id);
return Response.success();
} catch (e) {
return Response.error(e);
}
}
private async deleteOrganizationCollection(id: string, options: Options) {
if (options.organizationId == null || options.organizationId === "") {
return Response.badRequest("`organizationid` options is required.");
}
if (!Utils.isGuid(id)) {
return Response.badRequest("`" + id + "` is not a GUID.");
}
if (!Utils.isGuid(options.organizationId)) {
return Response.badRequest("`" + options.organizationId + "` is not a GUID.");
}
try {
await this.apiService.deleteCollection(options.organizationId, id);
return Response.success();
} catch (e) {
return Response.error(e);
}
}
}
class Options {
itemId: string;
organizationId: string;
permanent: boolean;
constructor(passedOptions: Record<string, any>) {
this.organizationId = passedOptions?.organizationid || passedOptions?.organizationId;
this.itemId = passedOptions?.itemid || passedOptions?.itemId;
this.permanent = CliUtils.convertBooleanOption(passedOptions?.permanent);
}
}

View File

@ -0,0 +1,43 @@
import * as fet from "node-fetch";
import { CryptoService } from "jslib-common/abstractions/crypto.service";
import { SymmetricCryptoKey } from "jslib-common/models/domain/symmetricCryptoKey";
import { Response } from "jslib-node/cli/models/response";
import { FileResponse } from "jslib-node/cli/models/response/fileResponse";
import { CliUtils } from "../utils";
export abstract class DownloadCommand {
constructor(protected cryptoService: CryptoService) {}
protected async saveAttachmentToFile(
url: string,
key: SymmetricCryptoKey,
fileName: string,
output?: string
) {
const response = await fet.default(new fet.Request(url, { headers: { cache: "no-cache" } }));
if (response.status !== 200) {
return Response.error(
"A " + response.status + " error occurred while downloading the attachment."
);
}
try {
const buf = await response.arrayBuffer();
const decBuf = await this.cryptoService.decryptFromBytes(buf, key);
if (process.env.BW_SERVE === "true") {
const res = new FileResponse(Buffer.from(decBuf), fileName);
return Response.success(res);
} else {
return await CliUtils.saveResultToFile(Buffer.from(decBuf), output, fileName);
}
} catch (e) {
if (typeof e === "string") {
return Response.error(e);
} else {
return Response.error("An error occurred while saving the attachment.");
}
}
}
}

View File

@ -0,0 +1,186 @@
import { ApiService } from "jslib-common/abstractions/api.service";
import { CipherService } from "jslib-common/abstractions/cipher.service";
import { CryptoService } from "jslib-common/abstractions/crypto.service";
import { FolderService } from "jslib-common/abstractions/folder.service";
import { Utils } from "jslib-common/misc/utils";
import { CipherExport } from "jslib-common/models/export/cipherExport";
import { CollectionExport } from "jslib-common/models/export/collectionExport";
import { FolderExport } from "jslib-common/models/export/folderExport";
import { CollectionRequest } from "jslib-common/models/request/collectionRequest";
import { SelectionReadOnlyRequest } from "jslib-common/models/request/selectionReadOnlyRequest";
import { Response } from "jslib-node/cli/models/response";
import { OrganizationCollectionRequest } from "../models/request/organizationCollectionRequest";
import { CipherResponse } from "../models/response/cipherResponse";
import { FolderResponse } from "../models/response/folderResponse";
import { OrganizationCollectionResponse } from "../models/response/organizationCollectionResponse";
import { CliUtils } from "../utils";
export class EditCommand {
constructor(
private cipherService: CipherService,
private folderService: FolderService,
private cryptoService: CryptoService,
private apiService: ApiService
) {}
async run(
object: string,
id: string,
requestJson: any,
cmdOptions: Record<string, any>
): Promise<Response> {
if (process.env.BW_SERVE !== "true" && (requestJson == null || requestJson === "")) {
requestJson = await CliUtils.readStdin();
}
if (requestJson == null || requestJson === "") {
return Response.badRequest("`requestJson` was not provided.");
}
let req: any = null;
if (typeof requestJson !== "string") {
req = requestJson;
} else {
try {
const reqJson = Buffer.from(requestJson, "base64").toString();
req = JSON.parse(reqJson);
} catch (e) {
return Response.badRequest("Error parsing the encoded request data.");
}
}
if (id != null) {
id = id.toLowerCase();
}
const normalizedOptions = new Options(cmdOptions);
switch (object.toLowerCase()) {
case "item":
return await this.editCipher(id, req);
case "item-collections":
return await this.editCipherCollections(id, req);
case "folder":
return await this.editFolder(id, req);
case "org-collection":
return await this.editOrganizationCollection(id, req, normalizedOptions);
default:
return Response.badRequest("Unknown object.");
}
}
private async editCipher(id: string, req: CipherExport) {
const cipher = await this.cipherService.get(id);
if (cipher == null) {
return Response.notFound();
}
let cipherView = await cipher.decrypt();
if (cipherView.isDeleted) {
return Response.badRequest("You may not edit a deleted item. Use the restore command first.");
}
cipherView = CipherExport.toView(req, cipherView);
const encCipher = await this.cipherService.encrypt(cipherView);
try {
await this.cipherService.saveWithServer(encCipher);
const updatedCipher = await this.cipherService.get(cipher.id);
const decCipher = await updatedCipher.decrypt();
const res = new CipherResponse(decCipher);
return Response.success(res);
} catch (e) {
return Response.error(e);
}
}
private async editCipherCollections(id: string, req: string[]) {
const cipher = await this.cipherService.get(id);
if (cipher == null) {
return Response.notFound();
}
if (cipher.organizationId == null) {
return Response.badRequest(
"Item does not belong to an organization. Consider moving it first."
);
}
cipher.collectionIds = req;
try {
await this.cipherService.saveCollectionsWithServer(cipher);
const updatedCipher = await this.cipherService.get(cipher.id);
const decCipher = await updatedCipher.decrypt();
const res = new CipherResponse(decCipher);
return Response.success(res);
} catch (e) {
return Response.error(e);
}
}
private async editFolder(id: string, req: FolderExport) {
const folder = await this.folderService.get(id);
if (folder == null) {
return Response.notFound();
}
let folderView = await folder.decrypt();
folderView = FolderExport.toView(req, folderView);
const encFolder = await this.folderService.encrypt(folderView);
try {
await this.folderService.saveWithServer(encFolder);
const updatedFolder = await this.folderService.get(folder.id);
const decFolder = await updatedFolder.decrypt();
const res = new FolderResponse(decFolder);
return Response.success(res);
} catch (e) {
return Response.error(e);
}
}
private async editOrganizationCollection(
id: string,
req: OrganizationCollectionRequest,
options: Options
) {
if (options.organizationId == null || options.organizationId === "") {
return Response.badRequest("`organizationid` option is required.");
}
if (!Utils.isGuid(id)) {
return Response.badRequest("`" + id + "` is not a GUID.");
}
if (!Utils.isGuid(options.organizationId)) {
return Response.badRequest("`" + options.organizationId + "` is not a GUID.");
}
if (options.organizationId !== req.organizationId) {
return Response.badRequest("`organizationid` option does not match request object.");
}
try {
const orgKey = await this.cryptoService.getOrgKey(req.organizationId);
if (orgKey == null) {
throw new Error("No encryption key for this organization.");
}
const groups =
req.groups == null
? null
: req.groups.map((g) => new SelectionReadOnlyRequest(g.id, g.readOnly, g.hidePasswords));
const request = new CollectionRequest();
request.name = (await this.cryptoService.encrypt(req.name, orgKey)).encryptedString;
request.externalId = req.externalId;
request.groups = groups;
const response = await this.apiService.putCollection(req.organizationId, id, request);
const view = CollectionExport.toView(req);
view.id = response.id;
const res = new OrganizationCollectionResponse(view, groups);
return Response.success(res);
} catch (e) {
return Response.error(e);
}
}
}
class Options {
organizationId: string;
constructor(passedOptions: Record<string, any>) {
this.organizationId = passedOptions?.organizationid || passedOptions?.organizationId;
}
}

View File

@ -0,0 +1,16 @@
import { Response } from "jslib-node/cli/models/response";
import { StringResponse } from "jslib-node/cli/models/response/stringResponse";
import { CliUtils } from "../utils";
export class EncodeCommand {
async run(): Promise<Response> {
if (process.stdin.isTTY) {
return Response.badRequest("No stdin was piped in.");
}
const input = await CliUtils.readStdin();
const b64 = Buffer.from(input, "utf8").toString("base64");
const res = new StringResponse(b64);
return Response.success(res);
}
}

View File

@ -0,0 +1,97 @@
import * as program from "commander";
import * as inquirer from "inquirer";
import { ExportFormat, ExportService } from "jslib-common/abstractions/export.service";
import { PolicyService } from "jslib-common/abstractions/policy.service";
import { PolicyType } from "jslib-common/enums/policyType";
import { Utils } from "jslib-common/misc/utils";
import { Response } from "jslib-node/cli/models/response";
import { CliUtils } from "../utils";
export class ExportCommand {
constructor(private exportService: ExportService, private policyService: PolicyService) {}
async run(options: program.OptionValues): Promise<Response> {
if (
options.organizationid == null &&
(await this.policyService.policyAppliesToUser(PolicyType.DisablePersonalVaultExport))
) {
return Response.badRequest(
"One or more organization policies prevents you from exporting your personal vault."
);
}
const format = options.format ?? "csv";
if (options.organizationid != null && !Utils.isGuid(options.organizationid)) {
return Response.error("`" + options.organizationid + "` is not a GUID.");
}
let exportContent: string = null;
try {
exportContent =
format === "encrypted_json"
? await this.getProtectedExport(options.password, options.organizationid)
: await this.getUnprotectedExport(format, options.organizationid);
} catch (e) {
return Response.error(e);
}
return await this.saveFile(exportContent, options, format);
}
private async getProtectedExport(passwordOption: string | boolean, organizationId?: string) {
const password = await this.promptPassword(passwordOption);
return password == null
? await this.exportService.getExport("encrypted_json", organizationId)
: await this.exportService.getPasswordProtectedExport(password, organizationId);
}
private async getUnprotectedExport(format: ExportFormat, organizationId?: string) {
return this.exportService.getExport(format, organizationId);
}
private async saveFile(
exportContent: string,
options: program.OptionValues,
format: ExportFormat
): Promise<Response> {
try {
const fileName = this.getFileName(format, options.organizationid != null ? "org" : null);
return await CliUtils.saveResultToFile(exportContent, options.output, fileName);
} catch (e) {
return Response.error(e.toString());
}
}
private getFileName(format: ExportFormat, prefix?: string) {
if (format === "encrypted_json") {
if (prefix == null) {
prefix = "encrypted";
} else {
prefix = "encrypted_" + prefix;
}
format = "json";
}
return this.exportService.getFileName(prefix, format);
}
private async promptPassword(password: string | boolean) {
// boolean => flag set with no value, we need to prompt for password
// string => flag set with value, use this value for password
// undefined/null/false => account protect, not password, no password needed
if (typeof password === "string") {
return password;
} else if (password) {
const answer: inquirer.Answers = await inquirer.createPromptModule({
output: process.stderr,
})({
type: "password",
name: "password",
message: "Export file password:",
});
return answer.password as string;
}
return null;
}
}

View File

@ -0,0 +1,80 @@
import { PasswordGenerationService } from "jslib-common/abstractions/passwordGeneration.service";
import { StateService } from "jslib-common/abstractions/state.service";
import { Response } from "jslib-node/cli/models/response";
import { StringResponse } from "jslib-node/cli/models/response/stringResponse";
import { CliUtils } from "../utils";
export class GenerateCommand {
constructor(
private passwordGenerationService: PasswordGenerationService,
private stateService: StateService
) {}
async run(cmdOptions: Record<string, any>): Promise<Response> {
const normalizedOptions = new Options(cmdOptions);
const options = {
uppercase: normalizedOptions.uppercase,
lowercase: normalizedOptions.lowercase,
number: normalizedOptions.number,
special: normalizedOptions.special,
length: normalizedOptions.length,
type: normalizedOptions.type,
wordSeparator: normalizedOptions.separator,
numWords: normalizedOptions.words,
capitalize: normalizedOptions.capitalize,
includeNumber: normalizedOptions.includeNumber,
};
const enforcedOptions = (await this.stateService.getIsAuthenticated())
? (await this.passwordGenerationService.enforcePasswordGeneratorPoliciesOnOptions(options))[0]
: options;
const password = await this.passwordGenerationService.generatePassword(enforcedOptions);
const res = new StringResponse(password);
return Response.success(res);
}
}
class Options {
uppercase: boolean;
lowercase: boolean;
number: boolean;
special: boolean;
length: number;
type: "passphrase" | "password";
separator: string;
words: number;
capitalize: boolean;
includeNumber: boolean;
constructor(passedOptions: Record<string, any>) {
this.uppercase = CliUtils.convertBooleanOption(passedOptions?.uppercase);
this.lowercase = CliUtils.convertBooleanOption(passedOptions?.lowercase);
this.number = CliUtils.convertBooleanOption(passedOptions?.number);
this.special = CliUtils.convertBooleanOption(passedOptions?.special);
this.capitalize = CliUtils.convertBooleanOption(passedOptions?.capitalize);
this.includeNumber = CliUtils.convertBooleanOption(passedOptions?.includeNumber);
this.length = passedOptions?.length != null ? parseInt(passedOptions?.length, null) : 14;
this.type = passedOptions?.passphrase ? "passphrase" : "password";
this.separator = passedOptions?.separator == null ? "-" : passedOptions.separator + "";
this.words = passedOptions?.words != null ? parseInt(passedOptions.words, null) : 3;
if (!this.uppercase && !this.lowercase && !this.special && !this.number) {
this.lowercase = true;
this.uppercase = true;
this.number = true;
}
if (this.length < 5) {
this.length = 5;
}
if (this.words < 3) {
this.words = 3;
}
if (this.separator === "space") {
this.separator = " ";
} else if (this.separator != null && this.separator.length > 1) {
this.separator = this.separator[0];
}
}
}

View File

@ -0,0 +1,540 @@
import { ApiService } from "jslib-common/abstractions/api.service";
import { AuditService } from "jslib-common/abstractions/audit.service";
import { CipherService } from "jslib-common/abstractions/cipher.service";
import { CollectionService } from "jslib-common/abstractions/collection.service";
import { CryptoService } from "jslib-common/abstractions/crypto.service";
import { FolderService } from "jslib-common/abstractions/folder.service";
import { OrganizationService } from "jslib-common/abstractions/organization.service";
import { SearchService } from "jslib-common/abstractions/search.service";
import { StateService } from "jslib-common/abstractions/state.service";
import { TotpService } from "jslib-common/abstractions/totp.service";
import { CipherType } from "jslib-common/enums/cipherType";
import { SendType } from "jslib-common/enums/sendType";
import { Utils } from "jslib-common/misc/utils";
import { EncString } from "jslib-common/models/domain/encString";
import { Organization } from "jslib-common/models/domain/organization";
import { CardExport } from "jslib-common/models/export/cardExport";
import { CipherExport } from "jslib-common/models/export/cipherExport";
import { CollectionExport } from "jslib-common/models/export/collectionExport";
import { FieldExport } from "jslib-common/models/export/fieldExport";
import { FolderExport } from "jslib-common/models/export/folderExport";
import { IdentityExport } from "jslib-common/models/export/identityExport";
import { LoginExport } from "jslib-common/models/export/loginExport";
import { LoginUriExport } from "jslib-common/models/export/loginUriExport";
import { SecureNoteExport } from "jslib-common/models/export/secureNoteExport";
import { ErrorResponse } from "jslib-common/models/response/errorResponse";
import { CipherView } from "jslib-common/models/view/cipherView";
import { CollectionView } from "jslib-common/models/view/collectionView";
import { FolderView } from "jslib-common/models/view/folderView";
import { Response } from "jslib-node/cli/models/response";
import { StringResponse } from "jslib-node/cli/models/response/stringResponse";
import { OrganizationCollectionRequest } from "../models/request/organizationCollectionRequest";
import { CipherResponse } from "../models/response/cipherResponse";
import { CollectionResponse } from "../models/response/collectionResponse";
import { FolderResponse } from "../models/response/folderResponse";
import { OrganizationCollectionResponse } from "../models/response/organizationCollectionResponse";
import { OrganizationResponse } from "../models/response/organizationResponse";
import { SendResponse } from "../models/response/sendResponse";
import { TemplateResponse } from "../models/response/templateResponse";
import { SelectionReadOnly } from "../models/selectionReadOnly";
import { CliUtils } from "../utils";
import { DownloadCommand } from "./download.command";
export class GetCommand extends DownloadCommand {
constructor(
private cipherService: CipherService,
private folderService: FolderService,
private collectionService: CollectionService,
private totpService: TotpService,
private auditService: AuditService,
cryptoService: CryptoService,
private stateService: StateService,
private searchService: SearchService,
private apiService: ApiService,
private organizationService: OrganizationService
) {
super(cryptoService);
}
async run(object: string, id: string, cmdOptions: Record<string, any>): Promise<Response> {
if (id != null) {
id = id.toLowerCase();
}
const normalizedOptions = new Options(cmdOptions);
switch (object.toLowerCase()) {
case "item":
return await this.getCipher(id);
case "username":
return await this.getUsername(id);
case "password":
return await this.getPassword(id);
case "uri":
return await this.getUri(id);
case "totp":
return await this.getTotp(id);
case "notes":
return await this.getNotes(id);
case "exposed":
return await this.getExposed(id);
case "attachment":
return await this.getAttachment(id, normalizedOptions);
case "folder":
return await this.getFolder(id);
case "collection":
return await this.getCollection(id);
case "org-collection":
return await this.getOrganizationCollection(id, normalizedOptions);
case "organization":
return await this.getOrganization(id);
case "template":
return await this.getTemplate(id);
case "fingerprint":
return await this.getFingerprint(id);
default:
return Response.badRequest("Unknown object.");
}
}
private async getCipherView(id: string): Promise<CipherView | CipherView[]> {
let decCipher: CipherView = null;
if (Utils.isGuid(id)) {
const cipher = await this.cipherService.get(id);
if (cipher != null) {
decCipher = await cipher.decrypt();
}
} else if (id.trim() !== "") {
let ciphers = await this.cipherService.getAllDecrypted();
ciphers = this.searchService.searchCiphersBasic(ciphers, id);
if (ciphers.length > 1) {
return ciphers;
}
if (ciphers.length > 0) {
decCipher = ciphers[0];
}
}
return decCipher;
}
private async getCipher(id: string, filter?: (c: CipherView) => boolean) {
let decCipher = await this.getCipherView(id);
if (decCipher == null) {
return Response.notFound();
}
if (Array.isArray(decCipher)) {
if (filter != null) {
decCipher = decCipher.filter(filter);
if (decCipher.length === 1) {
decCipher = decCipher[0];
}
}
if (Array.isArray(decCipher)) {
return Response.multipleResults(decCipher.map((c) => c.id));
}
}
const res = new CipherResponse(decCipher);
return Response.success(res);
}
private async getUsername(id: string) {
const cipherResponse = await this.getCipher(
id,
(c) => c.type === CipherType.Login && !Utils.isNullOrWhitespace(c.login.username)
);
if (!cipherResponse.success) {
return cipherResponse;
}
const cipher = cipherResponse.data as CipherResponse;
if (cipher.type !== CipherType.Login) {
return Response.badRequest("Not a login.");
}
if (Utils.isNullOrWhitespace(cipher.login.username)) {
return Response.error("No username available for this login.");
}
const res = new StringResponse(cipher.login.username);
return Response.success(res);
}
private async getPassword(id: string) {
const cipherResponse = await this.getCipher(
id,
(c) => c.type === CipherType.Login && !Utils.isNullOrWhitespace(c.login.password)
);
if (!cipherResponse.success) {
return cipherResponse;
}
const cipher = cipherResponse.data as CipherResponse;
if (cipher.type !== CipherType.Login) {
return Response.badRequest("Not a login.");
}
if (Utils.isNullOrWhitespace(cipher.login.password)) {
return Response.error("No password available for this login.");
}
const res = new StringResponse(cipher.login.password);
return Response.success(res);
}
private async getUri(id: string) {
const cipherResponse = await this.getCipher(
id,
(c) =>
c.type === CipherType.Login &&
c.login.uris != null &&
c.login.uris.length > 0 &&
c.login.uris[0].uri !== ""
);
if (!cipherResponse.success) {
return cipherResponse;
}
const cipher = cipherResponse.data as CipherResponse;
if (cipher.type !== CipherType.Login) {
return Response.badRequest("Not a login.");
}
if (
cipher.login.uris == null ||
cipher.login.uris.length === 0 ||
cipher.login.uris[0].uri === ""
) {
return Response.error("No uri available for this login.");
}
const res = new StringResponse(cipher.login.uris[0].uri);
return Response.success(res);
}
private async getTotp(id: string) {
const cipherResponse = await this.getCipher(
id,
(c) => c.type === CipherType.Login && !Utils.isNullOrWhitespace(c.login.totp)
);
if (!cipherResponse.success) {
return cipherResponse;
}
const cipher = cipherResponse.data as CipherResponse;
if (cipher.type !== CipherType.Login) {
return Response.badRequest("Not a login.");
}
if (Utils.isNullOrWhitespace(cipher.login.totp)) {
return Response.error("No TOTP available for this login.");
}
const totp = await this.totpService.getCode(cipher.login.totp);
if (totp == null) {
return Response.error("Couldn't generate TOTP code.");
}
const canAccessPremium = await this.stateService.getCanAccessPremium();
if (!canAccessPremium) {
const originalCipher = await this.cipherService.get(cipher.id);
if (
originalCipher == null ||
originalCipher.organizationId == null ||
!originalCipher.organizationUseTotp
) {
return Response.error("Premium status is required to use this feature.");
}
}
const res = new StringResponse(totp);
return Response.success(res);
}
private async getNotes(id: string) {
const cipherResponse = await this.getCipher(id, (c) => !Utils.isNullOrWhitespace(c.notes));
if (!cipherResponse.success) {
return cipherResponse;
}
const cipher = cipherResponse.data as CipherResponse;
if (Utils.isNullOrWhitespace(cipher.notes)) {
return Response.error("No notes available for this item.");
}
const res = new StringResponse(cipher.notes);
return Response.success(res);
}
private async getExposed(id: string) {
const passwordResponse = await this.getPassword(id);
if (!passwordResponse.success) {
return passwordResponse;
}
const exposedNumber = await this.auditService.passwordLeaked(
(passwordResponse.data as StringResponse).data
);
const res = new StringResponse(exposedNumber.toString());
return Response.success(res);
}
private async getAttachment(id: string, options: Options) {
if (options.itemId == null || options.itemId === "") {
return Response.badRequest("--itemid <itemid> required.");
}
const itemId = options.itemId.toLowerCase();
const cipherResponse = await this.getCipher(itemId);
if (!cipherResponse.success) {
return cipherResponse;
}
const cipher = await this.getCipherView(itemId);
if (
cipher == null ||
Array.isArray(cipher) ||
cipher.attachments == null ||
cipher.attachments.length === 0
) {
return Response.error("No attachments available for this item.");
}
let attachments = cipher.attachments.filter(
(a) =>
a.id.toLowerCase() === id ||
(a.fileName != null && a.fileName.toLowerCase().indexOf(id) > -1)
);
if (attachments.length === 0) {
return Response.error("Attachment `" + id + "` was not found.");
}
const exactMatches = attachments.filter((a) => a.fileName.toLowerCase() === id);
if (exactMatches.length === 1) {
attachments = exactMatches;
}
if (attachments.length > 1) {
return Response.multipleResults(attachments.map((a) => a.id));
}
if (!(await this.stateService.getCanAccessPremium())) {
const originalCipher = await this.cipherService.get(cipher.id);
if (originalCipher == null || originalCipher.organizationId == null) {
return Response.error("Premium status is required to use this feature.");
}
}
let url: string;
try {
const attachmentDownloadResponse = await this.apiService.getAttachmentData(
cipher.id,
attachments[0].id
);
url = attachmentDownloadResponse.url;
} catch (e) {
if (e instanceof ErrorResponse && (e as ErrorResponse).statusCode === 404) {
url = attachments[0].url;
} else if (e instanceof ErrorResponse) {
throw new Error((e as ErrorResponse).getSingleMessage());
} else {
throw e;
}
}
const key =
attachments[0].key != null
? attachments[0].key
: await this.cryptoService.getOrgKey(cipher.organizationId);
return await this.saveAttachmentToFile(url, key, attachments[0].fileName, options.output);
}
private async getFolder(id: string) {
let decFolder: FolderView = null;
if (Utils.isGuid(id)) {
const folder = await this.folderService.get(id);
if (folder != null) {
decFolder = await folder.decrypt();
}
} else if (id.trim() !== "") {
let folders = await this.folderService.getAllDecrypted();
folders = CliUtils.searchFolders(folders, id);
if (folders.length > 1) {
return Response.multipleResults(folders.map((f) => f.id));
}
if (folders.length > 0) {
decFolder = folders[0];
}
}
if (decFolder == null) {
return Response.notFound();
}
const res = new FolderResponse(decFolder);
return Response.success(res);
}
private async getCollection(id: string) {
let decCollection: CollectionView = null;
if (Utils.isGuid(id)) {
const collection = await this.collectionService.get(id);
if (collection != null) {
decCollection = await collection.decrypt();
}
} else if (id.trim() !== "") {
let collections = await this.collectionService.getAllDecrypted();
collections = CliUtils.searchCollections(collections, id);
if (collections.length > 1) {
return Response.multipleResults(collections.map((c) => c.id));
}
if (collections.length > 0) {
decCollection = collections[0];
}
}
if (decCollection == null) {
return Response.notFound();
}
const res = new CollectionResponse(decCollection);
return Response.success(res);
}
private async getOrganizationCollection(id: string, options: Options) {
if (options.organizationId == null || options.organizationId === "") {
return Response.badRequest("`organizationid` option is required.");
}
if (!Utils.isGuid(id)) {
return Response.badRequest("`" + id + "` is not a GUID.");
}
if (!Utils.isGuid(options.organizationId)) {
return Response.badRequest("`" + options.organizationId + "` is not a GUID.");
}
try {
const orgKey = await this.cryptoService.getOrgKey(options.organizationId);
if (orgKey == null) {
throw new Error("No encryption key for this organization.");
}
const response = await this.apiService.getCollectionDetails(options.organizationId, id);
const decCollection = new CollectionView(response);
decCollection.name = await this.cryptoService.decryptToUtf8(
new EncString(response.name),
orgKey
);
const groups =
response.groups == null
? null
: response.groups.map((g) => new SelectionReadOnly(g.id, g.readOnly, g.hidePasswords));
const res = new OrganizationCollectionResponse(decCollection, groups);
return Response.success(res);
} catch (e) {
return Response.error(e);
}
}
private async getOrganization(id: string) {
let org: Organization = null;
if (Utils.isGuid(id)) {
org = await this.organizationService.get(id);
} else if (id.trim() !== "") {
let orgs = await this.organizationService.getAll();
orgs = CliUtils.searchOrganizations(orgs, id);
if (orgs.length > 1) {
return Response.multipleResults(orgs.map((c) => c.id));
}
if (orgs.length > 0) {
org = orgs[0];
}
}
if (org == null) {
return Response.notFound();
}
const res = new OrganizationResponse(org);
return Response.success(res);
}
private async getTemplate(id: string) {
let template: any = null;
switch (id.toLowerCase()) {
case "item":
template = CipherExport.template();
break;
case "item.field":
template = FieldExport.template();
break;
case "item.login":
template = LoginExport.template();
break;
case "item.login.uri":
template = LoginUriExport.template();
break;
case "item.card":
template = CardExport.template();
break;
case "item.identity":
template = IdentityExport.template();
break;
case "item.securenote":
template = SecureNoteExport.template();
break;
case "folder":
template = FolderExport.template();
break;
case "collection":
template = CollectionExport.template();
break;
case "item-collections":
template = ["collection-id1", "collection-id2"];
break;
case "org-collection":
template = OrganizationCollectionRequest.template();
break;
case "send.text":
template = SendResponse.template(SendType.Text);
break;
case "send.file":
template = SendResponse.template(SendType.File);
break;
default:
return Response.badRequest("Unknown template object.");
}
const res = new TemplateResponse(template);
return Response.success(res);
}
private async getFingerprint(id: string) {
let fingerprint: string[] = null;
if (id === "me") {
fingerprint = await this.cryptoService.getFingerprint(await this.stateService.getUserId());
} else if (Utils.isGuid(id)) {
try {
const response = await this.apiService.getUserPublicKey(id);
const pubKey = Utils.fromB64ToArray(response.publicKey);
fingerprint = await this.cryptoService.getFingerprint(id, pubKey.buffer);
} catch {
// eslint-disable-next-line
}
}
if (fingerprint == null) {
return Response.notFound();
}
const res = new StringResponse(fingerprint.join("-"));
return Response.success(res);
}
}
class Options {
itemId: string;
organizationId: string;
output: string;
constructor(passedOptions: Record<string, any>) {
this.organizationId = passedOptions?.organizationid || passedOptions?.organizationId;
this.itemId = passedOptions?.itemid || passedOptions?.itemId;
this.output = passedOptions?.output;
}
}

View File

@ -0,0 +1,127 @@
import * as program from "commander";
import * as inquirer from "inquirer";
import { ImportService } from "jslib-common/abstractions/import.service";
import { OrganizationService } from "jslib-common/abstractions/organization.service";
import { ImportType } from "jslib-common/enums/importOptions";
import { Importer } from "jslib-common/importers/importer";
import { Response } from "jslib-node/cli/models/response";
import { MessageResponse } from "jslib-node/cli/models/response/messageResponse";
import { CliUtils } from "../utils";
export class ImportCommand {
constructor(
private importService: ImportService,
private organizationService: OrganizationService
) {}
async run(
format: ImportType,
filepath: string,
options: program.OptionValues
): Promise<Response> {
const organizationId = options.organizationid;
if (organizationId != null) {
const organization = await this.organizationService.get(organizationId);
if (organization == null) {
return Response.badRequest(
`You do not belong to an organization with the ID of ${organizationId}. Check the organization ID and sync your vault.`
);
}
if (!organization.canAccessImportExport) {
return Response.badRequest(
"You are not authorized to import into the provided organization."
);
}
}
if (options.formats || false) {
return await this.list();
} else {
return await this.import(format, filepath, organizationId);
}
}
private async import(format: ImportType, filepath: string, organizationId: string) {
if (format == null) {
return Response.badRequest("`format` was not provided.");
}
if (filepath == null || filepath === "") {
return Response.badRequest("`filepath` was not provided.");
}
const importer = await this.importService.getImporter(format, organizationId);
if (importer === null) {
return Response.badRequest("Proper importer type required.");
}
try {
let contents;
if (format === "1password1pux") {
contents = await CliUtils.extract1PuxContent(filepath);
} else {
contents = await CliUtils.readFile(filepath);
}
if (contents === null || contents === "") {
return Response.badRequest("Import file was empty.");
}
const response = await this.doImport(importer, contents, organizationId);
if (response.success) {
response.data = new MessageResponse("Imported " + filepath, null);
}
return response;
} catch (err) {
return Response.badRequest(err);
}
}
private async list() {
const options = this.importService
.getImportOptions()
.sort((a, b) => {
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
})
.map((option) => option.id)
.join("\n");
const res = new MessageResponse("Supported input formats:", options);
res.raw = options;
return Response.success(res);
}
private async doImport(
importer: Importer,
contents: string,
organizationId?: string
): Promise<Response> {
const err = await this.importService.import(importer, contents, organizationId);
if (err != null) {
if (err.passwordRequired) {
importer = this.importService.getImporter(
"bitwardenpasswordprotected",
organizationId,
await this.promptPassword()
);
return this.doImport(importer, contents, organizationId);
}
return Response.badRequest(err.message);
}
return Response.success();
}
private async promptPassword() {
const answer: inquirer.Answers = await inquirer.createPromptModule({
output: process.stderr,
})({
type: "password",
name: "password",
message: "Import file password:",
});
return answer.password;
}
}

View File

@ -0,0 +1,252 @@
import { ApiService } from "jslib-common/abstractions/api.service";
import { CipherService } from "jslib-common/abstractions/cipher.service";
import { CollectionService } from "jslib-common/abstractions/collection.service";
import { FolderService } from "jslib-common/abstractions/folder.service";
import { OrganizationService } from "jslib-common/abstractions/organization.service";
import { SearchService } from "jslib-common/abstractions/search.service";
import { Utils } from "jslib-common/misc/utils";
import { CollectionData } from "jslib-common/models/data/collectionData";
import { Collection } from "jslib-common/models/domain/collection";
import {
CollectionDetailsResponse as ApiCollectionDetailsResponse,
CollectionResponse as ApiCollectionResponse,
} from "jslib-common/models/response/collectionResponse";
import { ListResponse as ApiListResponse } from "jslib-common/models/response/listResponse";
import { CipherView } from "jslib-common/models/view/cipherView";
import { Response } from "jslib-node/cli/models/response";
import { ListResponse } from "jslib-node/cli/models/response/listResponse";
import { CipherResponse } from "../models/response/cipherResponse";
import { CollectionResponse } from "../models/response/collectionResponse";
import { FolderResponse } from "../models/response/folderResponse";
import { OrganizationResponse } from "../models/response/organizationResponse";
import { OrganizationUserResponse } from "../models/response/organizationUserResponse";
import { CliUtils } from "../utils";
export class ListCommand {
constructor(
private cipherService: CipherService,
private folderService: FolderService,
private collectionService: CollectionService,
private organizationService: OrganizationService,
private searchService: SearchService,
private apiService: ApiService
) {}
async run(object: string, cmdOptions: Record<string, any>): Promise<Response> {
const normalizedOptions = new Options(cmdOptions);
switch (object.toLowerCase()) {
case "items":
return await this.listCiphers(normalizedOptions);
case "folders":
return await this.listFolders(normalizedOptions);
case "collections":
return await this.listCollections(normalizedOptions);
case "org-collections":
return await this.listOrganizationCollections(normalizedOptions);
case "org-members":
return await this.listOrganizationMembers(normalizedOptions);
case "organizations":
return await this.listOrganizations(normalizedOptions);
default:
return Response.badRequest("Unknown object.");
}
}
private async listCiphers(options: Options) {
let ciphers: CipherView[];
options.trash = options.trash || false;
if (options.url != null && options.url.trim() !== "") {
ciphers = await this.cipherService.getAllDecryptedForUrl(options.url);
} else {
ciphers = await this.cipherService.getAllDecrypted();
}
if (
options.folderId != null ||
options.collectionId != null ||
options.organizationId != null
) {
ciphers = ciphers.filter((c) => {
if (options.trash !== c.isDeleted) {
return false;
}
if (options.folderId != null) {
if (options.folderId === "notnull" && c.folderId != null) {
return true;
}
const folderId = options.folderId === "null" ? null : options.folderId;
if (folderId === c.folderId) {
return true;
}
}
if (options.organizationId != null) {
if (options.organizationId === "notnull" && c.organizationId != null) {
return true;
}
const organizationId = options.organizationId === "null" ? null : options.organizationId;
if (organizationId === c.organizationId) {
return true;
}
}
if (options.collectionId != null) {
if (
options.collectionId === "notnull" &&
c.collectionIds != null &&
c.collectionIds.length > 0
) {
return true;
}
const collectionId = options.collectionId === "null" ? null : options.collectionId;
if (collectionId == null && (c.collectionIds == null || c.collectionIds.length === 0)) {
return true;
}
if (
collectionId != null &&
c.collectionIds != null &&
c.collectionIds.indexOf(collectionId) > -1
) {
return true;
}
}
return false;
});
} else if (options.search == null || options.search.trim() === "") {
ciphers = ciphers.filter((c) => options.trash === c.isDeleted);
}
if (options.search != null && options.search.trim() !== "") {
ciphers = this.searchService.searchCiphersBasic(ciphers, options.search, options.trash);
}
const res = new ListResponse(ciphers.map((o) => new CipherResponse(o)));
return Response.success(res);
}
private async listFolders(options: Options) {
let folders = await this.folderService.getAllDecrypted();
if (options.search != null && options.search.trim() !== "") {
folders = CliUtils.searchFolders(folders, options.search);
}
const res = new ListResponse(folders.map((o) => new FolderResponse(o)));
return Response.success(res);
}
private async listCollections(options: Options) {
let collections = await this.collectionService.getAllDecrypted();
if (options.organizationId != null) {
collections = collections.filter((c) => {
if (options.organizationId === c.organizationId) {
return true;
}
return false;
});
}
if (options.search != null && options.search.trim() !== "") {
collections = CliUtils.searchCollections(collections, options.search);
}
const res = new ListResponse(collections.map((o) => new CollectionResponse(o)));
return Response.success(res);
}
private async listOrganizationCollections(options: Options) {
if (options.organizationId == null || options.organizationId === "") {
return Response.badRequest("`organizationid` option is required.");
}
if (!Utils.isGuid(options.organizationId)) {
return Response.badRequest("`" + options.organizationId + "` is not a GUID.");
}
const organization = await this.organizationService.get(options.organizationId);
if (organization == null) {
return Response.error("Organization not found.");
}
try {
let response: ApiListResponse<ApiCollectionResponse>;
if (organization.canViewAllCollections) {
response = await this.apiService.getCollections(options.organizationId);
} else {
response = await this.apiService.getUserCollections();
}
const collections = response.data
.filter((c) => c.organizationId === options.organizationId)
.map((r) => new Collection(new CollectionData(r as ApiCollectionDetailsResponse)));
let decCollections = await this.collectionService.decryptMany(collections);
if (options.search != null && options.search.trim() !== "") {
decCollections = CliUtils.searchCollections(decCollections, options.search);
}
const res = new ListResponse(decCollections.map((o) => new CollectionResponse(o)));
return Response.success(res);
} catch (e) {
return Response.error(e);
}
}
private async listOrganizationMembers(options: Options) {
if (options.organizationId == null || options.organizationId === "") {
return Response.badRequest("`organizationid` option is required.");
}
if (!Utils.isGuid(options.organizationId)) {
return Response.badRequest("`" + options.organizationId + "` is not a GUID.");
}
const organization = await this.organizationService.get(options.organizationId);
if (organization == null) {
return Response.error("Organization not found.");
}
try {
const response = await this.apiService.getOrganizationUsers(options.organizationId);
const res = new ListResponse(
response.data.map((r) => {
const u = new OrganizationUserResponse();
u.email = r.email;
u.name = r.name;
u.id = r.id;
u.status = r.status;
u.type = r.type;
u.twoFactorEnabled = r.twoFactorEnabled;
return u;
})
);
return Response.success(res);
} catch (e) {
return Response.error(e);
}
}
private async listOrganizations(options: Options) {
let organizations = await this.organizationService.getAll();
if (options.search != null && options.search.trim() !== "") {
organizations = CliUtils.searchOrganizations(organizations, options.search);
}
const res = new ListResponse(organizations.map((o) => new OrganizationResponse(o)));
return Response.success(res);
}
}
class Options {
organizationId: string;
collectionId: string;
folderId: string;
search: string;
url: string;
trash: boolean;
constructor(passedOptions: Record<string, any>) {
this.organizationId = passedOptions?.organizationid || passedOptions?.organizationId;
this.collectionId = passedOptions?.collectionid || passedOptions?.collectionId;
this.folderId = passedOptions?.folderid || passedOptions?.folderId;
this.search = passedOptions?.search;
this.url = passedOptions?.url;
this.trash = CliUtils.convertBooleanOption(passedOptions?.trash);
}
}

View File

@ -0,0 +1,14 @@
import { VaultTimeoutService } from "jslib-common/abstractions/vaultTimeout.service";
import { Response } from "jslib-node/cli/models/response";
import { MessageResponse } from "jslib-node/cli/models/response/messageResponse";
export class LockCommand {
constructor(private vaultTimeoutService: VaultTimeoutService) {}
async run() {
await this.vaultTimeoutService.lock();
process.env.BW_SESSION = null;
const res = new MessageResponse("Your vault is locked.", null);
return Response.success(res);
}
}

View File

@ -0,0 +1,99 @@
import * as program from "commander";
import { ApiService } from "jslib-common/abstractions/api.service";
import { AuthService } from "jslib-common/abstractions/auth.service";
import { CryptoService } from "jslib-common/abstractions/crypto.service";
import { CryptoFunctionService } from "jslib-common/abstractions/cryptoFunction.service";
import { EnvironmentService } from "jslib-common/abstractions/environment.service";
import { I18nService } from "jslib-common/abstractions/i18n.service";
import { KeyConnectorService } from "jslib-common/abstractions/keyConnector.service";
import { PasswordGenerationService } from "jslib-common/abstractions/passwordGeneration.service";
import { PlatformUtilsService } from "jslib-common/abstractions/platformUtils.service";
import { PolicyService } from "jslib-common/abstractions/policy.service";
import { StateService } from "jslib-common/abstractions/state.service";
import { SyncService } from "jslib-common/abstractions/sync.service";
import { TwoFactorService } from "jslib-common/abstractions/twoFactor.service";
import { Utils } from "jslib-common/misc/utils";
import { LoginCommand as BaseLoginCommand } from "jslib-node/cli/commands/login.command";
import { MessageResponse } from "jslib-node/cli/models/response/messageResponse";
export class LoginCommand extends BaseLoginCommand {
private options: program.OptionValues;
constructor(
authService: AuthService,
apiService: ApiService,
cryptoFunctionService: CryptoFunctionService,
i18nService: I18nService,
environmentService: EnvironmentService,
passwordGenerationService: PasswordGenerationService,
platformUtilsService: PlatformUtilsService,
stateService: StateService,
cryptoService: CryptoService,
policyService: PolicyService,
twoFactorService: TwoFactorService,
private syncService: SyncService,
private keyConnectorService: KeyConnectorService,
private logoutCallback: () => Promise<void>
) {
super(
authService,
apiService,
i18nService,
environmentService,
passwordGenerationService,
cryptoFunctionService,
platformUtilsService,
stateService,
cryptoService,
policyService,
twoFactorService,
"cli"
);
this.logout = this.logoutCallback;
this.validatedParams = async () => {
const key = await cryptoFunctionService.randomBytes(64);
process.env.BW_SESSION = Utils.fromBufferToB64(key);
};
this.success = async () => {
await this.syncService.fullSync(true);
const usesKeyConnector = await this.keyConnectorService.getUsesKeyConnector();
if (
(this.options.sso != null || this.options.apikey != null) &&
this.canInteract &&
!usesKeyConnector
) {
const res = new MessageResponse(
"You are logged in!",
"\n" + "To unlock your vault, use the `unlock` command. ex:\n" + "$ bw unlock"
);
return res;
} else {
const res = new MessageResponse(
"You are logged in!",
"\n" +
"To unlock your vault, set your session key to the `BW_SESSION` environment variable. ex:\n" +
'$ export BW_SESSION="' +
process.env.BW_SESSION +
'"\n' +
'> $env:BW_SESSION="' +
process.env.BW_SESSION +
'"\n\n' +
"You can also pass the session key to any command with the `--session` option. ex:\n" +
"$ bw list items --session " +
process.env.BW_SESSION
);
res.raw = process.env.BW_SESSION;
return res;
}
};
}
run(email: string, password: string, options: program.OptionValues) {
this.options = options;
this.email = email;
return super.run(email, password, options);
}
}

View File

@ -0,0 +1,36 @@
import { CipherService } from "jslib-common/abstractions/cipher.service";
import { Response } from "jslib-node/cli/models/response";
export class RestoreCommand {
constructor(private cipherService: CipherService) {}
async run(object: string, id: string): Promise<Response> {
if (id != null) {
id = id.toLowerCase();
}
switch (object.toLowerCase()) {
case "item":
return await this.restoreCipher(id);
default:
return Response.badRequest("Unknown object.");
}
}
private async restoreCipher(id: string) {
const cipher = await this.cipherService.get(id);
if (cipher == null) {
return Response.notFound();
}
if (cipher.deletedDate == null) {
return Response.badRequest("Cipher is not in trash.");
}
try {
await this.cipherService.restoreWithServer(id);
return Response.success();
} catch (e) {
return Response.error(e);
}
}
}

View File

@ -0,0 +1,149 @@
import * as fs from "fs";
import * as path from "path";
import { EnvironmentService } from "jslib-common/abstractions/environment.service";
import { SendService } from "jslib-common/abstractions/send.service";
import { StateService } from "jslib-common/abstractions/state.service";
import { SendType } from "jslib-common/enums/sendType";
import { NodeUtils } from "jslib-common/misc/nodeUtils";
import { Response } from "jslib-node/cli/models/response";
import { SendResponse } from "../../models/response/sendResponse";
import { SendTextResponse } from "../../models/response/sendTextResponse";
import { CliUtils } from "../../utils";
export class SendCreateCommand {
constructor(
private sendService: SendService,
private stateService: StateService,
private environmentService: EnvironmentService
) {}
async run(requestJson: any, cmdOptions: Record<string, any>) {
let req: any = null;
if (process.env.BW_SERVE !== "true" && (requestJson == null || requestJson === "")) {
requestJson = await CliUtils.readStdin();
}
if (requestJson == null || requestJson === "") {
return Response.badRequest("`requestJson` was not provided.");
}
if (typeof requestJson !== "string") {
req = requestJson;
req.deletionDate = req.deletionDate == null ? null : new Date(req.deletionDate);
req.expirationDate = req.expirationDate == null ? null : new Date(req.expirationDate);
} else {
try {
const reqJson = Buffer.from(requestJson, "base64").toString();
req = SendResponse.fromJson(reqJson);
if (req == null) {
throw new Error("Null request");
}
} catch (e) {
return Response.badRequest("Error parsing the encoded request data.");
}
}
if (
req.deletionDate == null ||
isNaN(new Date(req.deletionDate).getTime()) ||
new Date(req.deletionDate) <= new Date()
) {
return Response.badRequest("Must specify a valid deletion date after the current time");
}
if (req.expirationDate != null && isNaN(new Date(req.expirationDate).getTime())) {
return Response.badRequest("Unable to parse expirationDate: " + req.expirationDate);
}
const normalizedOptions = new Options(cmdOptions);
return this.createSend(req, normalizedOptions);
}
private async createSend(req: SendResponse, options: Options) {
const filePath = req.file?.fileName ?? options.file;
const text = req.text?.text ?? options.text;
const hidden = req.text?.hidden ?? options.hidden;
const password = req.password ?? options.password;
const maxAccessCount = req.maxAccessCount ?? options.maxAccessCount;
req.key = null;
req.maxAccessCount = maxAccessCount;
switch (req.type) {
case SendType.File:
if (process.env.BW_SERVE === "true") {
return Response.error(
"Creating a file-based Send is unsupported through the `serve` command at this time."
);
}
if (!(await this.stateService.getCanAccessPremium())) {
return Response.error("Premium status is required to use this feature.");
}
if (filePath == null) {
return Response.badRequest(
"Must specify a file to Send either with the --file option or in the request JSON."
);
}
req.file.fileName = path.basename(filePath);
break;
case SendType.Text:
if (text == null) {
return Response.badRequest(
"Must specify text content to Send either with the --text option or in the request JSON."
);
}
req.text = new SendTextResponse();
req.text.text = text;
req.text.hidden = hidden;
break;
default:
return Response.badRequest(
"Unknown Send type " + SendType[req.type] + ". Valid types are: file, text"
);
}
try {
let fileBuffer: ArrayBuffer = null;
if (req.type === SendType.File) {
fileBuffer = NodeUtils.bufferToArrayBuffer(fs.readFileSync(filePath));
}
const sendView = SendResponse.toView(req);
const [encSend, fileData] = await this.sendService.encrypt(sendView, fileBuffer, password);
// Add dates from template
encSend.deletionDate = sendView.deletionDate;
encSend.expirationDate = sendView.expirationDate;
await this.sendService.saveWithServer([encSend, fileData]);
const newSend = await this.sendService.get(encSend.id);
const decSend = await newSend.decrypt();
const res = new SendResponse(decSend, this.environmentService.getWebVaultUrl());
return Response.success(res);
} catch (e) {
return Response.error(e);
}
}
}
class Options {
file: string;
text: string;
maxAccessCount: number;
password: string;
hidden: boolean;
constructor(passedOptions: Record<string, any>) {
this.file = passedOptions?.file;
this.text = passedOptions?.text;
this.password = passedOptions?.password;
this.hidden = CliUtils.convertBooleanOption(passedOptions?.hidden);
this.maxAccessCount =
passedOptions?.maxAccessCount != null ? parseInt(passedOptions.maxAccessCount, null) : null;
}
}

View File

@ -0,0 +1,21 @@
import { SendService } from "jslib-common/abstractions/send.service";
import { Response } from "jslib-node/cli/models/response";
export class SendDeleteCommand {
constructor(private sendService: SendService) {}
async run(id: string) {
const send = await this.sendService.get(id);
if (send == null) {
return Response.notFound();
}
try {
await this.sendService.deleteWithServer(id);
return Response.success();
} catch (e) {
return Response.error(e);
}
}
}

View File

@ -0,0 +1,90 @@
import { SendService } from "jslib-common/abstractions/send.service";
import { StateService } from "jslib-common/abstractions/state.service";
import { SendType } from "jslib-common/enums/sendType";
import { Response } from "jslib-node/cli/models/response";
import { SendResponse } from "../../models/response/sendResponse";
import { CliUtils } from "../../utils";
import { SendGetCommand } from "./get.command";
export class SendEditCommand {
constructor(
private sendService: SendService,
private stateService: StateService,
private getCommand: SendGetCommand
) {}
async run(requestJson: string, cmdOptions: Record<string, any>): Promise<Response> {
if (process.env.BW_SERVE !== "true" && (requestJson == null || requestJson === "")) {
requestJson = await CliUtils.readStdin();
}
if (requestJson == null || requestJson === "") {
return Response.badRequest("`requestJson` was not provided.");
}
let req: SendResponse = null;
if (typeof requestJson !== "string") {
req = requestJson;
req.deletionDate = req.deletionDate == null ? null : new Date(req.deletionDate);
req.expirationDate = req.expirationDate == null ? null : new Date(req.expirationDate);
} else {
try {
const reqJson = Buffer.from(requestJson, "base64").toString();
req = SendResponse.fromJson(reqJson);
} catch (e) {
return Response.badRequest("Error parsing the encoded request data.");
}
}
const normalizedOptions = new Options(cmdOptions);
req.id = normalizedOptions.itemId || req.id;
if (req.id != null) {
req.id = req.id.toLowerCase();
}
const send = await this.sendService.get(req.id);
if (send == null) {
return Response.notFound();
}
if (send.type !== req.type) {
return Response.badRequest("Cannot change a Send's type");
}
if (send.type === SendType.File && !(await this.stateService.getCanAccessPremium())) {
return Response.error("Premium status is required to use this feature.");
}
let sendView = await send.decrypt();
sendView = SendResponse.toView(req, sendView);
if (typeof req.password !== "string" || req.password === "") {
req.password = null;
}
try {
const [encSend, encFileData] = await this.sendService.encrypt(sendView, null, req.password);
// Add dates from template
encSend.deletionDate = sendView.deletionDate;
encSend.expirationDate = sendView.expirationDate;
await this.sendService.saveWithServer([encSend, encFileData]);
} catch (e) {
return Response.error(e);
}
return await this.getCommand.run(send.id, {});
}
}
class Options {
itemId: string;
constructor(passedOptions: Record<string, any>) {
this.itemId = passedOptions?.itemId || passedOptions?.itemid;
}
}

View File

@ -0,0 +1,83 @@
import * as program from "commander";
import { CryptoService } from "jslib-common/abstractions/crypto.service";
import { EnvironmentService } from "jslib-common/abstractions/environment.service";
import { SearchService } from "jslib-common/abstractions/search.service";
import { SendService } from "jslib-common/abstractions/send.service";
import { Utils } from "jslib-common/misc/utils";
import { SendView } from "jslib-common/models/view/sendView";
import { Response } from "jslib-node/cli/models/response";
import { SendResponse } from "../../models/response/sendResponse";
import { DownloadCommand } from "../download.command";
export class SendGetCommand extends DownloadCommand {
constructor(
private sendService: SendService,
private environmentService: EnvironmentService,
private searchService: SearchService,
cryptoService: CryptoService
) {
super(cryptoService);
}
async run(id: string, options: program.OptionValues) {
const serveCommand = process.env.BW_SERVE === "true";
if (serveCommand && !Utils.isGuid(id)) {
return Response.badRequest("`" + id + "` is not a GUID.");
}
let sends = await this.getSendView(id);
if (sends == null) {
return Response.notFound();
}
const webVaultUrl = this.environmentService.getWebVaultUrl();
let filter = (s: SendView) => true;
let selector = async (s: SendView): Promise<Response> =>
Response.success(new SendResponse(s, webVaultUrl));
if (!serveCommand && options?.text != null) {
filter = (s) => {
return filter(s) && s.text != null;
};
selector = async (s) => {
// Write to stdout and response success so we get the text string only to stdout
process.stdout.write(s.text.text);
return Response.success();
};
}
if (Array.isArray(sends)) {
if (filter != null) {
sends = sends.filter(filter);
}
if (sends.length > 1) {
return Response.multipleResults(sends.map((s) => s.id));
}
if (sends.length > 0) {
return selector(sends[0]);
} else {
return Response.notFound();
}
}
return selector(sends);
}
private async getSendView(id: string): Promise<SendView | SendView[]> {
if (Utils.isGuid(id)) {
const send = await this.sendService.get(id);
if (send != null) {
return await send.decrypt();
}
} else if (id.trim() !== "") {
let sends = await this.sendService.getAllDecrypted();
sends = this.searchService.searchSends(sends, id);
if (sends.length > 1) {
return sends;
} else if (sends.length > 0) {
return sends[0];
}
}
}
}

View File

@ -0,0 +1,36 @@
import { EnvironmentService } from "jslib-common/abstractions/environment.service";
import { SearchService } from "jslib-common/abstractions/search.service";
import { SendService } from "jslib-common/abstractions/send.service";
import { Response } from "jslib-node/cli/models/response";
import { ListResponse } from "jslib-node/cli/models/response/listResponse";
import { SendResponse } from "../..//models/response/sendResponse";
export class SendListCommand {
constructor(
private sendService: SendService,
private environmentService: EnvironmentService,
private searchService: SearchService
) {}
async run(cmdOptions: Record<string, any>): Promise<Response> {
let sends = await this.sendService.getAllDecrypted();
const normalizedOptions = new Options(cmdOptions);
if (normalizedOptions.search != null && normalizedOptions.search.trim() !== "") {
sends = this.searchService.searchSends(sends, normalizedOptions.search);
}
const webVaultUrl = this.environmentService.getWebVaultUrl();
const res = new ListResponse(sends.map((s) => new SendResponse(s, webVaultUrl)));
return Response.success(res);
}
}
class Options {
search: string;
constructor(passedOptions: Record<string, any>) {
this.search = passedOptions?.search;
}
}

View File

@ -0,0 +1,170 @@
import * as program from "commander";
import * as inquirer from "inquirer";
import { ApiService } from "jslib-common/abstractions/api.service";
import { CryptoService } from "jslib-common/abstractions/crypto.service";
import { CryptoFunctionService } from "jslib-common/abstractions/cryptoFunction.service";
import { EnvironmentService } from "jslib-common/abstractions/environment.service";
import { PlatformUtilsService } from "jslib-common/abstractions/platformUtils.service";
import { SendType } from "jslib-common/enums/sendType";
import { NodeUtils } from "jslib-common/misc/nodeUtils";
import { Utils } from "jslib-common/misc/utils";
import { SendAccess } from "jslib-common/models/domain/sendAccess";
import { SymmetricCryptoKey } from "jslib-common/models/domain/symmetricCryptoKey";
import { SendAccessRequest } from "jslib-common/models/request/sendAccessRequest";
import { ErrorResponse } from "jslib-common/models/response/errorResponse";
import { SendAccessView } from "jslib-common/models/view/sendAccessView";
import { Response } from "jslib-node/cli/models/response";
import { SendAccessResponse } from "../../models/response/sendAccessResponse";
import { DownloadCommand } from "../download.command";
export class SendReceiveCommand extends DownloadCommand {
private canInteract: boolean;
private decKey: SymmetricCryptoKey;
private sendAccessRequest: SendAccessRequest;
constructor(
private apiService: ApiService,
cryptoService: CryptoService,
private cryptoFunctionService: CryptoFunctionService,
private platformUtilsService: PlatformUtilsService,
private environmentService: EnvironmentService
) {
super(cryptoService);
}
async run(url: string, options: program.OptionValues): Promise<Response> {
this.canInteract = process.env.BW_NOINTERACTION !== "true";
let urlObject: URL;
try {
urlObject = new URL(url);
} catch (e) {
return Response.badRequest("Failed to parse the provided Send url");
}
const apiUrl = this.getApiUrl(urlObject);
const [id, key] = this.getIdAndKey(urlObject);
if (Utils.isNullOrWhitespace(id) || Utils.isNullOrWhitespace(key)) {
return Response.badRequest("Failed to parse url, the url provided is not a valid Send url");
}
const keyArray = Utils.fromUrlB64ToArray(key);
this.sendAccessRequest = new SendAccessRequest();
let password = options.password;
if (password == null || password === "") {
if (options.passwordfile) {
password = await NodeUtils.readFirstLine(options.passwordfile);
} else if (options.passwordenv && process.env[options.passwordenv]) {
password = process.env[options.passwordenv];
}
}
if (password != null && password !== "") {
this.sendAccessRequest.password = await this.getUnlockedPassword(password, keyArray);
}
const response = await this.sendRequest(apiUrl, id, keyArray);
if (response instanceof Response) {
// Error scenario
return response;
}
if (options.obj != null) {
return Response.success(new SendAccessResponse(response));
}
switch (response.type) {
case SendType.Text:
// Write to stdout and response success so we get the text string only to stdout
process.stdout.write(response?.text?.text);
return Response.success();
case SendType.File: {
const downloadData = await this.apiService.getSendFileDownloadData(
response,
this.sendAccessRequest,
apiUrl
);
return await this.saveAttachmentToFile(
downloadData.url,
this.decKey,
response?.file?.fileName,
options.output
);
}
default:
return Response.success(new SendAccessResponse(response));
}
}
private getIdAndKey(url: URL): [string, string] {
const result = url.hash.slice(1).split("/").slice(-2);
return [result[0], result[1]];
}
private getApiUrl(url: URL) {
const urls = this.environmentService.getUrls();
if (url.origin === "https://send.bitwarden.com") {
return "https://vault.bitwarden.com/api";
} else if (url.origin === urls.api) {
return url.origin;
} else if (this.platformUtilsService.isDev() && url.origin === urls.webVault) {
return urls.api;
} else {
return url.origin + "/api";
}
}
private async getUnlockedPassword(password: string, keyArray: ArrayBuffer) {
const passwordHash = await this.cryptoFunctionService.pbkdf2(
password,
keyArray,
"sha256",
100000
);
return Utils.fromBufferToB64(passwordHash);
}
private async sendRequest(
url: string,
id: string,
key: ArrayBuffer
): Promise<Response | SendAccessView> {
try {
const sendResponse = await this.apiService.postSendAccess(id, this.sendAccessRequest, url);
const sendAccess = new SendAccess(sendResponse);
this.decKey = await this.cryptoService.makeSendKey(key);
return await sendAccess.decrypt(this.decKey);
} catch (e) {
if (e instanceof ErrorResponse) {
if (e.statusCode === 401) {
if (this.canInteract) {
const answer: inquirer.Answers = await inquirer.createPromptModule({
output: process.stderr,
})({
type: "password",
name: "password",
message: "Send password:",
});
// reattempt with new password
this.sendAccessRequest.password = await this.getUnlockedPassword(answer.password, key);
return await this.sendRequest(url, id, key);
}
return Response.badRequest("Incorrect or missing password");
} else if (e.statusCode === 405) {
return Response.badRequest("Bad Request");
} else if (e.statusCode === 404) {
return Response.notFound();
}
}
return Response.error(e);
}
}
}

View File

@ -0,0 +1,21 @@
import { SendService } from "jslib-common/abstractions/send.service";
import { Response } from "jslib-node/cli/models/response";
import { SendResponse } from "../../models/response/sendResponse";
export class SendRemovePasswordCommand {
constructor(private sendService: SendService) {}
async run(id: string) {
try {
await this.sendService.removePasswordWithServer(id);
const updatedSend = await this.sendService.get(id);
const decSend = await updatedSend.decrypt();
const res = new SendResponse(decSend);
return Response.success(res);
} catch (e) {
return Response.error(e);
}
}
}

View File

@ -0,0 +1,394 @@
import * as koaMulter from "@koa/multer";
import * as koaRouter from "@koa/router";
import * as program from "commander";
import * as koa from "koa";
import * as koaBodyParser from "koa-bodyparser";
import * as koaJson from "koa-json";
import { KeySuffixOptions } from "jslib-common/enums/keySuffixOptions";
import { Response } from "jslib-node/cli/models/response";
import { FileResponse } from "jslib-node/cli/models/response/fileResponse";
import { Main } from "../bw";
import { ConfirmCommand } from "./confirm.command";
import { CreateCommand } from "./create.command";
import { DeleteCommand } from "./delete.command";
import { EditCommand } from "./edit.command";
import { GenerateCommand } from "./generate.command";
import { GetCommand } from "./get.command";
import { ListCommand } from "./list.command";
import { LockCommand } from "./lock.command";
import { RestoreCommand } from "./restore.command";
import { SendCreateCommand } from "./send/create.command";
import { SendDeleteCommand } from "./send/delete.command";
import { SendEditCommand } from "./send/edit.command";
import { SendGetCommand } from "./send/get.command";
import { SendListCommand } from "./send/list.command";
import { SendRemovePasswordCommand } from "./send/removePassword.command";
import { ShareCommand } from "./share.command";
import { StatusCommand } from "./status.command";
import { SyncCommand } from "./sync.command";
import { UnlockCommand } from "./unlock.command";
export class ServeCommand {
private listCommand: ListCommand;
private getCommand: GetCommand;
private createCommand: CreateCommand;
private editCommand: EditCommand;
private generateCommand: GenerateCommand;
private shareCommand: ShareCommand;
private statusCommand: StatusCommand;
private syncCommand: SyncCommand;
private deleteCommand: DeleteCommand;
private confirmCommand: ConfirmCommand;
private restoreCommand: RestoreCommand;
private lockCommand: LockCommand;
private unlockCommand: UnlockCommand;
private sendCreateCommand: SendCreateCommand;
private sendDeleteCommand: SendDeleteCommand;
private sendEditCommand: SendEditCommand;
private sendGetCommand: SendGetCommand;
private sendListCommand: SendListCommand;
private sendRemovePasswordCommand: SendRemovePasswordCommand;
constructor(protected main: Main) {
this.getCommand = new GetCommand(
this.main.cipherService,
this.main.folderService,
this.main.collectionService,
this.main.totpService,
this.main.auditService,
this.main.cryptoService,
this.main.stateService,
this.main.searchService,
this.main.apiService,
this.main.organizationService
);
this.listCommand = new ListCommand(
this.main.cipherService,
this.main.folderService,
this.main.collectionService,
this.main.organizationService,
this.main.searchService,
this.main.apiService
);
this.createCommand = new CreateCommand(
this.main.cipherService,
this.main.folderService,
this.main.stateService,
this.main.cryptoService,
this.main.apiService
);
this.editCommand = new EditCommand(
this.main.cipherService,
this.main.folderService,
this.main.cryptoService,
this.main.apiService
);
this.generateCommand = new GenerateCommand(
this.main.passwordGenerationService,
this.main.stateService
);
this.syncCommand = new SyncCommand(this.main.syncService);
this.statusCommand = new StatusCommand(
this.main.environmentService,
this.main.syncService,
this.main.stateService,
this.main.authService
);
this.deleteCommand = new DeleteCommand(
this.main.cipherService,
this.main.folderService,
this.main.stateService,
this.main.apiService
);
this.confirmCommand = new ConfirmCommand(this.main.apiService, this.main.cryptoService);
this.restoreCommand = new RestoreCommand(this.main.cipherService);
this.shareCommand = new ShareCommand(this.main.cipherService);
this.lockCommand = new LockCommand(this.main.vaultTimeoutService);
this.unlockCommand = new UnlockCommand(
this.main.cryptoService,
this.main.stateService,
this.main.cryptoFunctionService,
this.main.apiService,
this.main.logService,
this.main.keyConnectorService,
this.main.environmentService,
this.main.syncService,
async () => await this.main.logout()
);
this.sendCreateCommand = new SendCreateCommand(
this.main.sendService,
this.main.stateService,
this.main.environmentService
);
this.sendDeleteCommand = new SendDeleteCommand(this.main.sendService);
this.sendGetCommand = new SendGetCommand(
this.main.sendService,
this.main.environmentService,
this.main.searchService,
this.main.cryptoService
);
this.sendEditCommand = new SendEditCommand(
this.main.sendService,
this.main.stateService,
this.sendGetCommand
);
this.sendListCommand = new SendListCommand(
this.main.sendService,
this.main.environmentService,
this.main.searchService
);
this.sendRemovePasswordCommand = new SendRemovePasswordCommand(this.main.sendService);
}
async run(options: program.OptionValues) {
const port = options.port || 8087;
const hostname = options.hostname || "localhost";
const server = new koa();
const router = new koaRouter();
process.env.BW_SERVE = "true";
process.env.BW_NOINTERACTION = "true";
server.use(koaBodyParser()).use(koaJson({ pretty: false, param: "pretty" }));
router.get("/generate", async (ctx, next) => {
const response = await this.generateCommand.run(ctx.request.query);
this.processResponse(ctx.response, response);
await next();
});
router.get("/status", async (ctx, next) => {
const response = await this.statusCommand.run();
this.processResponse(ctx.response, response);
await next();
});
router.get("/list/object/:object", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
response = await this.sendListCommand.run(ctx.request.query);
} else {
response = await this.listCommand.run(ctx.params.object, ctx.request.query);
}
this.processResponse(ctx.response, response);
await next();
});
router.get("/send/list", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.sendListCommand.run(ctx.request.query);
this.processResponse(ctx.response, response);
await next();
});
router.post("/sync", async (ctx, next) => {
const response = await this.syncCommand.run(ctx.request.query);
this.processResponse(ctx.response, response);
await next();
});
router.post("/lock", async (ctx, next) => {
const response = await this.lockCommand.run();
this.processResponse(ctx.response, response);
await next();
});
router.post("/unlock", async (ctx, next) => {
const response = await this.unlockCommand.run(
ctx.request.body.password == null ? null : (ctx.request.body.password as string),
ctx.request.query
);
this.processResponse(ctx.response, response);
await next();
});
router.post("/confirm/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.confirmCommand.run(
ctx.params.object,
ctx.params.id,
ctx.request.query
);
this.processResponse(ctx.response, response);
await next();
});
router.post("/restore/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.restoreCommand.run(ctx.params.object, ctx.params.id);
this.processResponse(ctx.response, response);
await next();
});
router.post("/move/:id/:organizationId", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.shareCommand.run(
ctx.params.id,
ctx.params.organizationId,
ctx.request.body // TODO: Check the format of this body for an array of collection ids
);
this.processResponse(ctx.response, response);
await next();
});
router.post("/attachment", koaMulter().single("file"), async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.createCommand.run(
"attachment",
ctx.request.body,
ctx.request.query,
{
fileBuffer: ctx.request.file.buffer,
fileName: ctx.request.file.originalname,
}
);
this.processResponse(ctx.response, response);
await next();
});
router.post("/send/:id/remove-password", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
const response = await this.sendRemovePasswordCommand.run(ctx.params.id);
this.processResponse(ctx.response, response);
await next();
});
router.post("/object/:object", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
response = await this.sendCreateCommand.run(ctx.request.body, ctx.request.query);
} else {
response = await this.createCommand.run(
ctx.params.object,
ctx.request.body,
ctx.request.query
);
}
this.processResponse(ctx.response, response);
await next();
});
router.put("/object/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
ctx.request.body.id = ctx.params.id;
response = await this.sendEditCommand.run(ctx.request.body, ctx.request.query);
} else {
response = await this.editCommand.run(
ctx.params.object,
ctx.params.id,
ctx.request.body,
ctx.request.query
);
}
this.processResponse(ctx.response, response);
await next();
});
router.get("/object/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
response = await this.sendGetCommand.run(ctx.params.id, null);
} else {
response = await this.getCommand.run(ctx.params.object, ctx.params.id, ctx.request.query);
}
this.processResponse(ctx.response, response);
await next();
});
router.delete("/object/:object/:id", async (ctx, next) => {
if (await this.errorIfLocked(ctx.response)) {
await next();
return;
}
let response: Response = null;
if (ctx.params.object === "send") {
response = await this.sendDeleteCommand.run(ctx.params.id);
} else {
response = await this.deleteCommand.run(
ctx.params.object,
ctx.params.id,
ctx.request.query
);
}
this.processResponse(ctx.response, response);
await next();
});
server
.use(router.routes())
.use(router.allowedMethods())
.listen(port, hostname === "all" ? null : hostname, () => {
this.main.logService.info("Listening on " + hostname + ":" + port);
});
}
private processResponse(res: koa.Response, commandResponse: Response) {
if (!commandResponse.success) {
res.status = 400;
}
if (commandResponse.data instanceof FileResponse) {
res.body = commandResponse.data.data;
res.attachment(commandResponse.data.fileName);
res.set("Content-Type", "application/octet-stream");
res.set("Content-Length", commandResponse.data.data.length.toString());
} else {
res.body = commandResponse;
}
}
private async errorIfLocked(res: koa.Response) {
const authed = await this.main.stateService.getIsAuthenticated();
if (!authed) {
this.processResponse(res, Response.error("You are not logged in."));
return true;
}
if (await this.main.cryptoService.hasKeyInMemory()) {
return false;
} else if (await this.main.cryptoService.hasKeyStored(KeySuffixOptions.Auto)) {
// load key into memory
await this.main.cryptoService.getKey();
return false;
}
this.processResponse(res, Response.error("Vault is locked."));
return true;
}
}

View File

@ -0,0 +1,59 @@
import { CipherService } from "jslib-common/abstractions/cipher.service";
import { Response } from "jslib-node/cli/models/response";
import { CipherResponse } from "../models/response/cipherResponse";
import { CliUtils } from "../utils";
export class ShareCommand {
constructor(private cipherService: CipherService) {}
async run(id: string, organizationId: string, requestJson: string): Promise<Response> {
if (process.env.BW_SERVE !== "true" && (requestJson == null || requestJson === "")) {
requestJson = await CliUtils.readStdin();
}
if (requestJson == null || requestJson === "") {
return Response.badRequest("`requestJson` was not provided.");
}
let req: string[] = [];
if (typeof requestJson !== "string") {
req = requestJson;
} else {
try {
const reqJson = Buffer.from(requestJson, "base64").toString();
req = JSON.parse(reqJson);
if (req == null || req.length === 0) {
return Response.badRequest("You must provide at least one collection id for this item.");
}
} catch (e) {
return Response.badRequest("Error parsing the encoded request data.");
}
}
if (id != null) {
id = id.toLowerCase();
}
if (organizationId != null) {
organizationId = organizationId.toLowerCase();
}
const cipher = await this.cipherService.get(id);
if (cipher == null) {
return Response.notFound();
}
if (cipher.organizationId != null) {
return Response.badRequest("This item already belongs to an organization.");
}
const cipherView = await cipher.decrypt();
try {
await this.cipherService.shareWithServer(cipherView, organizationId, req);
const updatedCipher = await this.cipherService.get(cipher.id);
const decCipher = await updatedCipher.decrypt();
const res = new CipherResponse(decCipher);
return Response.success(res);
} catch (e) {
return Response.error(e);
}
}
}

View File

@ -0,0 +1,54 @@
import { AuthService } from "jslib-common/abstractions/auth.service";
import { EnvironmentService } from "jslib-common/abstractions/environment.service";
import { StateService } from "jslib-common/abstractions/state.service";
import { SyncService } from "jslib-common/abstractions/sync.service";
import { AuthenticationStatus } from "jslib-common/enums/authenticationStatus";
import { Response } from "jslib-node/cli/models/response";
import { TemplateResponse } from "../models/response/templateResponse";
export class StatusCommand {
constructor(
private envService: EnvironmentService,
private syncService: SyncService,
private stateService: StateService,
private authService: AuthService
) {}
async run(): Promise<Response> {
try {
const baseUrl = this.baseUrl();
const status = await this.status();
const lastSync = await this.syncService.getLastSync();
const userId = await this.stateService.getUserId();
const email = await this.stateService.getEmail();
return Response.success(
new TemplateResponse({
serverUrl: baseUrl,
lastSync: lastSync,
userEmail: email,
userId: userId,
status: status,
})
);
} catch (e) {
return Response.error(e);
}
}
private baseUrl(): string {
return this.envService.getUrls().base;
}
private async status(): Promise<"unauthenticated" | "locked" | "unlocked"> {
const authStatus = await this.authService.getAuthStatus();
if (authStatus === AuthenticationStatus.Unlocked) {
return "unlocked";
} else if (authStatus === AuthenticationStatus.Locked) {
return "locked";
} else {
return "unauthenticated";
}
}
}

View File

@ -0,0 +1,41 @@
import { SyncService } from "jslib-common/abstractions/sync.service";
import { Response } from "jslib-node/cli/models/response";
import { MessageResponse } from "jslib-node/cli/models/response/messageResponse";
import { StringResponse } from "jslib-node/cli/models/response/stringResponse";
import { CliUtils } from "src/utils";
export class SyncCommand {
constructor(private syncService: SyncService) {}
async run(cmdOptions: Record<string, any>): Promise<Response> {
const normalizedOptions = new Options(cmdOptions);
if (normalizedOptions.last) {
return await this.getLastSync();
}
try {
await this.syncService.fullSync(normalizedOptions.force, true);
const res = new MessageResponse("Syncing complete.", null);
return Response.success(res);
} catch (e) {
return Response.error("Syncing failed: " + e.toString());
}
}
private async getLastSync() {
const lastSyncDate = await this.syncService.getLastSync();
const res = new StringResponse(lastSyncDate == null ? null : lastSyncDate.toISOString());
return Response.success(res);
}
}
class Options {
last: boolean;
force: boolean;
constructor(passedOptions: Record<string, any>) {
this.last = CliUtils.convertBooleanOption(passedOptions?.last);
this.force = CliUtils.convertBooleanOption(passedOptions?.force);
}
}

View File

@ -0,0 +1,132 @@
import { ApiService } from "jslib-common/abstractions/api.service";
import { CryptoService } from "jslib-common/abstractions/crypto.service";
import { CryptoFunctionService } from "jslib-common/abstractions/cryptoFunction.service";
import { EnvironmentService } from "jslib-common/abstractions/environment.service";
import { KeyConnectorService } from "jslib-common/abstractions/keyConnector.service";
import { StateService } from "jslib-common/abstractions/state.service";
import { SyncService } from "jslib-common/abstractions/sync.service";
import { HashPurpose } from "jslib-common/enums/hashPurpose";
import { Utils } from "jslib-common/misc/utils";
import { SecretVerificationRequest } from "jslib-common/models/request/secretVerificationRequest";
import { ConsoleLogService } from "jslib-common/services/consoleLog.service";
import { Response } from "jslib-node/cli/models/response";
import { MessageResponse } from "jslib-node/cli/models/response/messageResponse";
import { CliUtils } from "../utils";
import { ConvertToKeyConnectorCommand } from "./convertToKeyConnector.command";
export class UnlockCommand {
constructor(
private cryptoService: CryptoService,
private stateService: StateService,
private cryptoFunctionService: CryptoFunctionService,
private apiService: ApiService,
private logService: ConsoleLogService,
private keyConnectorService: KeyConnectorService,
private environmentService: EnvironmentService,
private syncService: SyncService,
private logout: () => Promise<void>
) {}
async run(password: string, cmdOptions: Record<string, any>) {
const normalizedOptions = new Options(cmdOptions);
const passwordResult = await CliUtils.getPassword(password, normalizedOptions, this.logService);
if (passwordResult instanceof Response) {
return passwordResult;
} else {
password = passwordResult;
}
await this.setNewSessionKey();
const email = await this.stateService.getEmail();
const kdf = await this.stateService.getKdfType();
const kdfIterations = await this.stateService.getKdfIterations();
const key = await this.cryptoService.makeKey(password, email, kdf, kdfIterations);
const storedKeyHash = await this.cryptoService.getKeyHash();
let passwordValid = false;
if (key != null) {
if (storedKeyHash != null) {
passwordValid = await this.cryptoService.compareAndUpdateKeyHash(password, key);
} else {
const serverKeyHash = await this.cryptoService.hashPassword(
password,
key,
HashPurpose.ServerAuthorization
);
const request = new SecretVerificationRequest();
request.masterPasswordHash = serverKeyHash;
try {
await this.apiService.postAccountVerifyPassword(request);
passwordValid = true;
const localKeyHash = await this.cryptoService.hashPassword(
password,
key,
HashPurpose.LocalAuthorization
);
await this.cryptoService.setKeyHash(localKeyHash);
} catch {
// Ignore
}
}
}
if (passwordValid) {
await this.cryptoService.setKey(key);
if (await this.keyConnectorService.getConvertAccountRequired()) {
const convertToKeyConnectorCommand = new ConvertToKeyConnectorCommand(
this.apiService,
this.keyConnectorService,
this.environmentService,
this.syncService,
this.logout
);
const convertResponse = await convertToKeyConnectorCommand.run();
if (!convertResponse.success) {
return convertResponse;
}
}
return this.successResponse();
} else {
return Response.error("Invalid master password.");
}
}
private async setNewSessionKey() {
const key = await this.cryptoFunctionService.randomBytes(64);
process.env.BW_SESSION = Utils.fromBufferToB64(key);
}
private async successResponse() {
const res = new MessageResponse(
"Your vault is now unlocked!",
"\n" +
"To unlock your vault, set your session key to the `BW_SESSION` environment variable. ex:\n" +
'$ export BW_SESSION="' +
process.env.BW_SESSION +
'"\n' +
'> $env:BW_SESSION="' +
process.env.BW_SESSION +
'"\n\n' +
"You can also pass the session key to any command with the `--session` option. ex:\n" +
"$ bw list items --session " +
process.env.BW_SESSION
);
res.raw = process.env.BW_SESSION;
return Response.success(res);
}
}
class Options {
passwordEnv: string;
passwordFile: string;
constructor(passedOptions: Record<string, any>) {
this.passwordEnv = passedOptions?.passwordenv || passedOptions?.passwordEnv;
this.passwordFile = passedOptions?.passwordfile || passedOptions?.passwordFile;
}
}

5
apps/cli/src/flags.ts Normal file
View File

@ -0,0 +1,5 @@
export type Flags = {
serve?: boolean;
};
export type FlagName = keyof Flags;

View File

@ -0,0 +1,44 @@
{
"bitwarden": {
"message": "Bitwarden"
},
"authenticatorAppTitle": {
"message": "Authenticator App"
},
"yubiKeyTitle": {
"message": "YubiKey OTP Security Key"
},
"emailTitle": {
"message": "Email"
},
"noneFolder": {
"message": "No Folder"
},
"importEncKeyError": {
"message": "Invalid file password."
},
"importPasswordRequired": {
"message": "File is password protected, please provide a decryption password."
},
"importFormatError": {
"message": "Data is not formatted correctly. Please check your import file and try again."
},
"importNothingError": {
"message": "Nothing was imported."
},
"verificationCodeRequired": {
"message": "Verification code is required."
},
"invalidVerificationCode": {
"message": "Invalid verification code."
},
"masterPassRequired": {
"message": "Master password is required."
},
"invalidMasterPassword": {
"message": "Invalid master password."
},
"sessionTimeout": {
"message": "Your session has timed out. Please go back and try logging in again."
}
}

View File

@ -0,0 +1,16 @@
import { CollectionExport } from "jslib-common/models/export/collectionExport";
import { SelectionReadOnly } from "../selectionReadOnly";
export class OrganizationCollectionRequest extends CollectionExport {
static template(): OrganizationCollectionRequest {
const req = new OrganizationCollectionRequest();
req.organizationId = "00000000-0000-0000-0000-000000000000";
req.name = "Collection name";
req.externalId = null;
req.groups = [SelectionReadOnly.template(), SelectionReadOnly.template()];
return req;
}
groups: SelectionReadOnly[];
}

View File

@ -0,0 +1,17 @@
import { AttachmentView } from "jslib-common/models/view/attachmentView";
export class AttachmentResponse {
id: string;
fileName: string;
size: string;
sizeName: string;
url: string;
constructor(o: AttachmentView) {
this.id = o.id;
this.fileName = o.fileName;
this.size = o.size;
this.sizeName = o.sizeName;
this.url = o.url;
}
}

View File

@ -0,0 +1,33 @@
import { CipherType } from "jslib-common/enums/cipherType";
import { CipherWithIdExport } from "jslib-common/models/export/cipherWithIdsExport";
import { CipherView } from "jslib-common/models/view/cipherView";
import { BaseResponse } from "jslib-node/cli/models/response/baseResponse";
import { AttachmentResponse } from "./attachmentResponse";
import { LoginResponse } from "./loginResponse";
import { PasswordHistoryResponse } from "./passwordHistoryResponse";
export class CipherResponse extends CipherWithIdExport implements BaseResponse {
object: string;
attachments: AttachmentResponse[];
revisionDate: Date;
deletedDate: Date;
passwordHistory: PasswordHistoryResponse[];
constructor(o: CipherView) {
super();
this.object = "item";
this.build(o);
if (o.attachments != null) {
this.attachments = o.attachments.map((a) => new AttachmentResponse(a));
}
this.revisionDate = o.revisionDate;
this.deletedDate = o.deletedDate;
if (o.passwordHistory != null) {
this.passwordHistory = o.passwordHistory.map((h) => new PasswordHistoryResponse(h));
}
if (o.type === CipherType.Login && o.login != null) {
this.login = new LoginResponse(o.login);
}
}
}

View File

@ -0,0 +1,13 @@
import { CollectionWithIdExport } from "jslib-common/models/export/collectionWithIdExport";
import { CollectionView } from "jslib-common/models/view/collectionView";
import { BaseResponse } from "jslib-node/cli/models/response/baseResponse";
export class CollectionResponse extends CollectionWithIdExport implements BaseResponse {
object: string;
constructor(o: CollectionView) {
super();
this.object = "collection";
this.build(o);
}
}

View File

@ -0,0 +1,13 @@
import { FolderWithIdExport } from "jslib-common/models/export/folderWithIdExport";
import { FolderView } from "jslib-common/models/view/folderView";
import { BaseResponse } from "jslib-node/cli/models/response/baseResponse";
export class FolderResponse extends FolderWithIdExport implements BaseResponse {
object: string;
constructor(o: FolderView) {
super();
this.object = "folder";
this.build(o);
}
}

View File

@ -0,0 +1,11 @@
import { LoginExport } from "jslib-common/models/export/loginExport";
import { LoginView } from "jslib-common/models/view/loginView";
export class LoginResponse extends LoginExport {
passwordRevisionDate: Date;
constructor(o: LoginView) {
super(o);
this.passwordRevisionDate = o.passwordRevisionDate != null ? o.passwordRevisionDate : null;
}
}

View File

@ -0,0 +1,15 @@
import { CollectionView } from "jslib-common/models/view/collectionView";
import { SelectionReadOnly } from "../selectionReadOnly";
import { CollectionResponse } from "./collectionResponse";
export class OrganizationCollectionResponse extends CollectionResponse {
groups: SelectionReadOnly[];
constructor(o: CollectionView, groups: SelectionReadOnly[]) {
super(o);
this.object = "org-collection";
this.groups = groups;
}
}

View File

@ -0,0 +1,22 @@
import { OrganizationUserStatusType } from "jslib-common/enums/organizationUserStatusType";
import { OrganizationUserType } from "jslib-common/enums/organizationUserType";
import { Organization } from "jslib-common/models/domain/organization";
import { BaseResponse } from "jslib-node/cli/models/response/baseResponse";
export class OrganizationResponse implements BaseResponse {
object: string;
id: string;
name: string;
status: OrganizationUserStatusType;
type: OrganizationUserType;
enabled: boolean;
constructor(o: Organization) {
this.object = "organization";
this.id = o.id;
this.name = o.name;
this.status = o.status;
this.type = o.type;
this.enabled = o.enabled;
}
}

View File

@ -0,0 +1,17 @@
import { OrganizationUserStatusType } from "jslib-common/enums/organizationUserStatusType";
import { OrganizationUserType } from "jslib-common/enums/organizationUserType";
import { BaseResponse } from "jslib-node/cli/models/response/baseResponse";
export class OrganizationUserResponse implements BaseResponse {
object: string;
id: string;
email: string;
name: string;
status: OrganizationUserStatusType;
type: OrganizationUserType;
twoFactorEnabled: boolean;
constructor() {
this.object = "org-member";
}
}

View File

@ -0,0 +1,11 @@
import { PasswordHistoryView } from "jslib-common/models/view/passwordHistoryView";
export class PasswordHistoryResponse {
lastUsedDate: Date;
password: string;
constructor(o: PasswordHistoryView) {
this.lastUsedDate = o.lastUsedDate;
this.password = o.password;
}
}

View File

@ -0,0 +1,40 @@
import { SendType } from "jslib-common/enums/sendType";
import { SendAccessView } from "jslib-common/models/view/sendAccessView";
import { BaseResponse } from "jslib-node/cli/models/response/baseResponse";
import { SendFileResponse } from "./sendFileResponse";
import { SendTextResponse } from "./sendTextResponse";
export class SendAccessResponse implements BaseResponse {
static template(): SendAccessResponse {
const req = new SendAccessResponse();
req.name = "Send name";
req.type = SendType.Text;
req.text = null;
req.file = null;
return req;
}
object = "send-access";
id: string;
name: string;
type: SendType;
text: SendTextResponse;
file: SendFileResponse;
constructor(o?: SendAccessView) {
if (o == null) {
return;
}
this.id = o.id;
this.name = o.name;
this.type = o.type;
if (o.type === SendType.Text && o.text != null) {
this.text = new SendTextResponse(o.text);
}
if (o.type === SendType.File && o.file != null) {
this.file = new SendFileResponse(o.file);
}
}
}

View File

@ -0,0 +1,36 @@
import { SendFileView } from "jslib-common/models/view/sendFileView";
export class SendFileResponse {
static template(fileName = "file attachment location"): SendFileResponse {
const req = new SendFileResponse();
req.fileName = fileName;
return req;
}
static toView(file: SendFileResponse, view = new SendFileView()) {
if (file == null) {
return null;
}
view.id = file.id;
view.size = file.size;
view.sizeName = file.sizeName;
view.fileName = file.fileName;
return view;
}
id: string;
size: string;
sizeName: string;
fileName: string;
constructor(o?: SendFileView) {
if (o == null) {
return;
}
this.id = o.id;
this.size = o.size;
this.sizeName = o.sizeName;
this.fileName = o.fileName;
}
}

View File

@ -0,0 +1,123 @@
import { SendType } from "jslib-common/enums/sendType";
import { Utils } from "jslib-common/misc/utils";
import { SendView } from "jslib-common/models/view/sendView";
import { BaseResponse } from "jslib-node/cli/models/response/baseResponse";
import { SendFileResponse } from "./sendFileResponse";
import { SendTextResponse } from "./sendTextResponse";
const dateProperties: string[] = [
Utils.nameOf<SendResponse>("deletionDate"),
Utils.nameOf<SendResponse>("expirationDate"),
];
export class SendResponse implements BaseResponse {
static template(sendType?: SendType, deleteInDays = 7): SendResponse {
const req = new SendResponse();
req.name = "Send name";
req.notes = "Some notes about this send.";
req.type = sendType === SendType.File ? SendType.File : SendType.Text;
req.text = sendType === SendType.Text ? SendTextResponse.template() : null;
req.file = sendType === SendType.File ? SendFileResponse.template() : null;
req.maxAccessCount = null;
req.deletionDate = this.getStandardDeletionDate(deleteInDays);
req.expirationDate = null;
req.password = null;
req.disabled = false;
req.hideEmail = false;
return req;
}
static toView(send: SendResponse, view = new SendView()): SendView {
if (send == null) {
return null;
}
view.id = send.id;
view.accessId = send.accessId;
view.name = send.name;
view.notes = send.notes;
view.key = send.key == null ? null : Utils.fromB64ToArray(send.key);
view.type = send.type;
view.file = SendFileResponse.toView(send.file);
view.text = SendTextResponse.toView(send.text);
view.maxAccessCount = send.maxAccessCount;
view.accessCount = send.accessCount;
view.revisionDate = send.revisionDate;
view.deletionDate = send.deletionDate;
view.expirationDate = send.expirationDate;
view.password = send.password;
view.disabled = send.disabled;
view.hideEmail = send.hideEmail;
return view;
}
static fromJson(json: string) {
return JSON.parse(json, (key, value) => {
if (dateProperties.includes(key)) {
return value == null ? null : new Date(value);
}
return value;
});
}
private static getStandardDeletionDate(days: number) {
const d = new Date();
d.setTime(d.getTime() + days * 86400000); // ms per day
return d;
}
object = "send";
id: string;
accessId: string;
accessUrl: string;
name: string;
notes: string;
key: string;
type: SendType;
text: SendTextResponse;
file: SendFileResponse;
maxAccessCount?: number;
accessCount: number;
revisionDate: Date;
deletionDate: Date;
expirationDate: Date;
password: string;
passwordSet: boolean;
disabled: boolean;
hideEmail: boolean;
constructor(o?: SendView, webVaultUrl?: string) {
if (o == null) {
return;
}
this.id = o.id;
this.accessId = o.accessId;
let sendLinkBaseUrl = webVaultUrl;
if (sendLinkBaseUrl == null) {
sendLinkBaseUrl = "https://send.bitwarden.com/#";
} else {
sendLinkBaseUrl += "/#/send/";
}
this.accessUrl = sendLinkBaseUrl + this.accessId + "/" + o.urlB64Key;
this.name = o.name;
this.notes = o.notes;
this.key = Utils.fromBufferToB64(o.key);
this.type = o.type;
this.maxAccessCount = o.maxAccessCount;
this.accessCount = o.accessCount;
this.revisionDate = o.revisionDate;
this.deletionDate = o.deletionDate;
this.expirationDate = o.expirationDate;
this.passwordSet = o.password != null;
this.disabled = o.disabled;
this.hideEmail = o.hideEmail;
if (o.type === SendType.Text && o.text != null) {
this.text = new SendTextResponse(o.text);
}
if (o.type === SendType.File && o.file != null) {
this.file = new SendFileResponse(o.file);
}
}
}

View File

@ -0,0 +1,30 @@
import { SendTextView } from "jslib-common/models/view/sendTextView";
export class SendTextResponse {
static template(text = "Text contained in the send.", hidden = false): SendTextResponse {
const req = new SendTextResponse();
req.text = text;
req.hidden = hidden;
return req;
}
static toView(text: SendTextResponse, view = new SendTextView()) {
if (text == null) {
return null;
}
view.text = text.text;
view.hidden = text.hidden;
return view;
}
text: string;
hidden: boolean;
constructor(o?: SendTextView) {
if (o == null) {
return;
}
this.text = o.text;
this.hidden = o.hidden;
}
}

View File

@ -0,0 +1,11 @@
import { BaseResponse } from "jslib-node/cli/models/response/baseResponse";
export class TemplateResponse implements BaseResponse {
object: string;
template: any;
constructor(template: any) {
this.object = "template";
this.template = template;
}
}

View File

@ -0,0 +1,15 @@
export class SelectionReadOnly {
static template(): SelectionReadOnly {
return new SelectionReadOnly("00000000-0000-0000-0000-000000000000", false, false);
}
id: string;
readOnly: boolean;
hidePasswords: boolean;
constructor(id: string, readOnly: boolean, hidePasswords: boolean) {
this.id = id;
this.readOnly = readOnly;
this.hidePasswords = hidePasswords || false;
}
}

546
apps/cli/src/program.ts Normal file
View File

@ -0,0 +1,546 @@
import * as chalk from "chalk";
import * as program from "commander";
import { AuthenticationStatus } from "jslib-common/enums/authenticationStatus";
import { KeySuffixOptions } from "jslib-common/enums/keySuffixOptions";
import { BaseProgram } from "jslib-node/cli/baseProgram";
import { LogoutCommand } from "jslib-node/cli/commands/logout.command";
import { UpdateCommand } from "jslib-node/cli/commands/update.command";
import { Response } from "jslib-node/cli/models/response";
import { MessageResponse } from "jslib-node/cli/models/response/messageResponse";
import { Main } from "./bw";
import { CompletionCommand } from "./commands/completion.command";
import { ConfigCommand } from "./commands/config.command";
import { EncodeCommand } from "./commands/encode.command";
import { GenerateCommand } from "./commands/generate.command";
import { LockCommand } from "./commands/lock.command";
import { LoginCommand } from "./commands/login.command";
import { ServeCommand } from "./commands/serve.command";
import { StatusCommand } from "./commands/status.command";
import { SyncCommand } from "./commands/sync.command";
import { UnlockCommand } from "./commands/unlock.command";
import { TemplateResponse } from "./models/response/templateResponse";
import { CliUtils } from "./utils";
const writeLn = CliUtils.writeLn;
export class Program extends BaseProgram {
constructor(protected main: Main) {
super(main.stateService, writeLn);
}
async register() {
program
.option("--pretty", "Format output. JSON is tabbed with two spaces.")
.option("--raw", "Return raw output instead of a descriptive message.")
.option("--response", "Return a JSON formatted version of response output.")
.option("--cleanexit", "Exit with a success exit code (0) unless an error is thrown.")
.option("--quiet", "Don't return anything to stdout.")
.option("--nointeraction", "Do not prompt for interactive user input.")
.option("--session <session>", "Pass session key instead of reading from env.")
.version(await this.main.platformUtilsService.getApplicationVersion(), "-v, --version");
program.on("option:pretty", () => {
process.env.BW_PRETTY = "true";
});
program.on("option:raw", () => {
process.env.BW_RAW = "true";
});
program.on("option:quiet", () => {
process.env.BW_QUIET = "true";
});
program.on("option:response", () => {
process.env.BW_RESPONSE = "true";
});
program.on("option:cleanexit", () => {
process.env.BW_CLEANEXIT = "true";
});
program.on("option:nointeraction", () => {
process.env.BW_NOINTERACTION = "true";
});
program.on("option:session", (key) => {
process.env.BW_SESSION = key;
});
program.on("command:*", () => {
writeLn(chalk.redBright("Invalid command: " + program.args.join(" ")), false, true);
writeLn("See --help for a list of available commands.", true, true);
process.exitCode = 1;
});
program.on("--help", () => {
writeLn("\n Examples:");
writeLn("");
writeLn(" bw login");
writeLn(" bw lock");
writeLn(" bw unlock myPassword321");
writeLn(" bw list --help");
writeLn(" bw list items --search google");
writeLn(" bw get item 99ee88d2-6046-4ea7-92c2-acac464b1412");
writeLn(" bw get password google.com");
writeLn(' echo \'{"name":"My Folder"}\' | bw encode');
writeLn(" bw create folder eyJuYW1lIjoiTXkgRm9sZGVyIn0K");
writeLn(
" bw edit folder c7c7b60b-9c61-40f2-8ccd-36c49595ed72 eyJuYW1lIjoiTXkgRm9sZGVyMiJ9Cg=="
);
writeLn(" bw delete item 99ee88d2-6046-4ea7-92c2-acac464b1412");
writeLn(" bw generate -lusn --length 18");
writeLn(" bw config server https://bitwarden.example.com");
writeLn(" bw send -f ./file.ext");
writeLn(' bw send "text to send"');
writeLn(' echo "text to send" | bw send');
writeLn(
" bw receive https://vault.bitwarden.com/#/send/rg3iuoS_Akm2gqy6ADRHmg/Ht7dYjsqjmgqUM3rjzZDSQ"
);
writeLn("", true);
});
program
.command("login [email] [password]")
.description("Log into a user account.")
.option("--method <method>", "Two-step login method.")
.option("--code <code>", "Two-step login code.")
.option("--sso", "Log in with Single-Sign On.")
.option("--apikey", "Log in with an Api Key.")
.option("--passwordenv <passwordenv>", "Environment variable storing your password")
.option(
"--passwordfile <passwordfile>",
"Path to a file containing your password as its first line"
)
.option("--check", "Check login status.", async () => {
const authed = await this.main.stateService.getIsAuthenticated();
if (authed) {
const res = new MessageResponse("You are logged in!", null);
this.processResponse(Response.success(res), true);
}
this.processResponse(Response.error("You are not logged in."), true);
})
.on("--help", () => {
writeLn("\n Notes:");
writeLn("");
writeLn(" See docs for valid `method` enum values.");
writeLn("");
writeLn(" Pass `--raw` option to only return the session key.");
writeLn("");
writeLn(" Examples:");
writeLn("");
writeLn(" bw login");
writeLn(" bw login john@example.com myPassword321 --raw");
writeLn(" bw login john@example.com myPassword321 --method 1 --code 249213");
writeLn(" bw login --sso");
writeLn("", true);
})
.action(async (email: string, password: string, options: program.OptionValues) => {
if (!options.check) {
await this.exitIfAuthed();
const command = new LoginCommand(
this.main.authService,
this.main.apiService,
this.main.cryptoFunctionService,
this.main.i18nService,
this.main.environmentService,
this.main.passwordGenerationService,
this.main.platformUtilsService,
this.main.stateService,
this.main.cryptoService,
this.main.policyService,
this.main.twoFactorService,
this.main.syncService,
this.main.keyConnectorService,
async () => await this.main.logout()
);
const response = await command.run(email, password, options);
this.processResponse(response);
}
});
program
.command("logout")
.description("Log out of the current user account.")
.on("--help", () => {
writeLn("\n Examples:");
writeLn("");
writeLn(" bw logout");
writeLn("", true);
})
.action(async (cmd) => {
await this.exitIfNotAuthed();
const command = new LogoutCommand(
this.main.authService,
this.main.i18nService,
async () => await this.main.logout()
);
const response = await command.run();
this.processResponse(response);
});
program
.command("lock")
.description("Lock the vault and destroy active session keys.")
.on("--help", () => {
writeLn("\n Examples:");
writeLn("");
writeLn(" bw lock");
writeLn("", true);
})
.action(async (cmd) => {
await this.exitIfNotAuthed();
if (await this.main.keyConnectorService.getUsesKeyConnector()) {
const logoutCommand = new LogoutCommand(
this.main.authService,
this.main.i18nService,
async () => await this.main.logout()
);
await logoutCommand.run();
this.processResponse(
Response.error(
"You cannot lock your vault because you are using Key Connector. " +
"To protect your vault, you have been logged out."
),
true
);
return;
}
const command = new LockCommand(this.main.vaultTimeoutService);
const response = await command.run();
this.processResponse(response);
});
program
.command("unlock [password]")
.description("Unlock the vault and return a new session key.")
.on("--help", () => {
writeLn("\n Notes:");
writeLn("");
writeLn(" After unlocking, any previous session keys will no longer be valid.");
writeLn("");
writeLn(" Pass `--raw` option to only return the session key.");
writeLn("");
writeLn(" Examples:");
writeLn("");
writeLn(" bw unlock");
writeLn(" bw unlock myPassword321");
writeLn(" bw unlock myPassword321 --raw");
writeLn("", true);
})
.option("--check", "Check lock status.", async () => {
await this.exitIfNotAuthed();
const authStatus = await this.main.authService.getAuthStatus();
if (authStatus === AuthenticationStatus.Unlocked) {
const res = new MessageResponse("Vault is unlocked!", null);
this.processResponse(Response.success(res), true);
} else {
this.processResponse(Response.error("Vault is locked."), true);
}
})
.option("--passwordenv <passwordenv>", "Environment variable storing your password")
.option(
"--passwordfile <passwordfile>",
"Path to a file containing your password as its first line"
)
.action(async (password, cmd) => {
if (!cmd.check) {
await this.exitIfNotAuthed();
const command = new UnlockCommand(
this.main.cryptoService,
this.main.stateService,
this.main.cryptoFunctionService,
this.main.apiService,
this.main.logService,
this.main.keyConnectorService,
this.main.environmentService,
this.main.syncService,
async () => await this.main.logout()
);
const response = await command.run(password, cmd);
this.processResponse(response);
}
});
program
.command("sync")
.description("Pull the latest vault data from server.")
.option("-f, --force", "Force a full sync.")
.option("--last", "Get the last sync date.")
.on("--help", () => {
writeLn("\n Examples:");
writeLn("");
writeLn(" bw sync");
writeLn(" bw sync -f");
writeLn(" bw sync --last");
writeLn("", true);
})
.action(async (cmd) => {
await this.exitIfLocked();
const command = new SyncCommand(this.main.syncService);
const response = await command.run(cmd);
this.processResponse(response);
});
program
.command("generate")
.description("Generate a password/passphrase.")
.option("-u, --uppercase", "Include uppercase characters.")
.option("-l, --lowercase", "Include lowercase characters.")
.option("-n, --number", "Include numeric characters.")
.option("-s, --special", "Include special characters.")
.option("-p, --passphrase", "Generate a passphrase.")
.option("--length <length>", "Length of the password.")
.option("--words <words>", "Number of words.")
.option("--separator <separator>", "Word separator.")
.option("-c, --capitalize", "Title case passphrase.")
.option("--includeNumber", "Passphrase includes number.")
.on("--help", () => {
writeLn("\n Notes:");
writeLn("");
writeLn(" Default options are `-uln --length 14`.");
writeLn("");
writeLn(" Minimum `length` is 5.");
writeLn("");
writeLn(" Minimum `words` is 3.");
writeLn("");
writeLn(" Examples:");
writeLn("");
writeLn(" bw generate");
writeLn(" bw generate -u -l --length 18");
writeLn(" bw generate -ulns --length 25");
writeLn(" bw generate -ul");
writeLn(" bw generate -p --separator _");
writeLn(" bw generate -p --words 5 --separator space");
writeLn("", true);
})
.action(async (options) => {
const command = new GenerateCommand(
this.main.passwordGenerationService,
this.main.stateService
);
const response = await command.run(options);
this.processResponse(response);
});
program
.command("encode")
.description("Base 64 encode stdin.")
.on("--help", () => {
writeLn("\n Notes:");
writeLn("");
writeLn(" Use to create `encodedJson` for `create` and `edit` commands.");
writeLn("");
writeLn(" Examples:");
writeLn("");
writeLn(' echo \'{"name":"My Folder"}\' | bw encode');
writeLn("", true);
})
.action(async () => {
const command = new EncodeCommand();
const response = await command.run();
this.processResponse(response);
});
program
.command("config <setting> [value]")
.description("Configure CLI settings.")
.option(
"--web-vault <url>",
"Provides a custom web vault URL that differs from the base URL."
)
.option("--api <url>", "Provides a custom API URL that differs from the base URL.")
.option("--identity <url>", "Provides a custom identity URL that differs from the base URL.")
.option(
"--icons <url>",
"Provides a custom icons service URL that differs from the base URL."
)
.option(
"--notifications <url>",
"Provides a custom notifications URL that differs from the base URL."
)
.option("--events <url>", "Provides a custom events URL that differs from the base URL.")
.option("--key-connector <url>", "Provides the URL for your Key Connector server.")
.on("--help", () => {
writeLn("\n Settings:");
writeLn("");
writeLn(" server - On-premises hosted installation URL.");
writeLn("");
writeLn(" Examples:");
writeLn("");
writeLn(" bw config server");
writeLn(" bw config server https://bw.company.com");
writeLn(" bw config server bitwarden.com");
writeLn(
" bw config server --api http://localhost:4000 --identity http://localhost:33656"
);
writeLn("", true);
})
.action(async (setting, value, options) => {
const command = new ConfigCommand(this.main.environmentService);
const response = await command.run(setting, value, options);
this.processResponse(response);
});
program
.command("update")
.description("Check for updates.")
.on("--help", () => {
writeLn("\n Notes:");
writeLn("");
writeLn(" Returns the URL to download the newest version of this CLI tool.");
writeLn("");
writeLn(" Use the `--raw` option to return only the download URL for the update.");
writeLn("");
writeLn(" Examples:");
writeLn("");
writeLn(" bw update");
writeLn(" bw update --raw");
writeLn("", true);
})
.action(async () => {
const command = new UpdateCommand(
this.main.platformUtilsService,
this.main.i18nService,
"cli",
"bw",
true
);
const response = await command.run();
this.processResponse(response);
});
program
.command("completion")
.description("Generate shell completions.")
.option("--shell <shell>", "Shell to generate completions for.")
.on("--help", () => {
writeLn("\n Notes:");
writeLn("");
writeLn(" Valid shells are `zsh`.");
writeLn("");
writeLn(" Examples:");
writeLn("");
writeLn(" bw completion --shell zsh");
writeLn("", true);
})
.action(async (options: program.OptionValues, cmd: program.Command) => {
const command = new CompletionCommand();
const response = await command.run(options);
this.processResponse(response);
});
program
.command("status")
.description("Show server, last sync, user information, and vault status.")
.on("--help", () => {
writeLn("");
writeLn("");
writeLn(" Example return value:");
writeLn("");
writeLn(" {");
writeLn(' "serverUrl": "https://bitwarden.example.com",');
writeLn(' "lastSync": "2020-06-16T06:33:51.419Z",');
writeLn(' "userEmail": "user@example.com,');
writeLn(' "userId": "00000000-0000-0000-0000-000000000000",');
writeLn(' "status": "locked"');
writeLn(" }");
writeLn("");
writeLn(" Notes:");
writeLn("");
writeLn(" `status` is one of:");
writeLn(" - `unauthenticated` when you are not logged in");
writeLn(" - `locked` when you are logged in and the vault is locked");
writeLn(" - `unlocked` when you are logged in and the vault is unlocked");
writeLn("", true);
})
.action(async () => {
const command = new StatusCommand(
this.main.environmentService,
this.main.syncService,
this.main.stateService,
this.main.authService
);
const response = await command.run();
this.processResponse(response);
});
if (CliUtils.flagEnabled("serve")) {
program
.command("serve")
.description("Start a RESTful API webserver.")
.option("--hostname <hostname>", "The hostname to bind your API webserver to.")
.option("--port <port>", "The port to run your API webserver on.")
.on("--help", () => {
writeLn("\n Notes:");
writeLn("");
writeLn(" Default hostname is `localhost`.");
writeLn(" Use hostname `all` for no hostname binding.");
writeLn(" Default port is `8087`.");
writeLn("");
writeLn(" Examples:");
writeLn("");
writeLn(" bw serve");
writeLn(" bw serve --port 8080");
writeLn(" bw serve --hostname bwapi.mydomain.com --port 80");
writeLn("", true);
})
.action(async (cmd) => {
await this.exitIfNotAuthed();
const command = new ServeCommand(this.main);
await command.run(cmd);
});
}
}
protected processResponse(response: Response, exitImmediately = false) {
super.processResponse(response, exitImmediately, () => {
if (response.data.object === "template") {
return this.getJson((response.data as TemplateResponse).template);
}
return null;
});
}
protected async exitIfLocked() {
await this.exitIfNotAuthed();
if (await this.main.cryptoService.hasKeyInMemory()) {
return;
} else if (await this.main.cryptoService.hasKeyStored(KeySuffixOptions.Auto)) {
// load key into memory
await this.main.cryptoService.getKey();
} else if (process.env.BW_NOINTERACTION !== "true") {
// must unlock
if (await this.main.keyConnectorService.getUsesKeyConnector()) {
const response = Response.error(
"Your vault is locked. You must unlock your vault using your session key.\n" +
"If you do not have your session key, you can get a new one by logging out and logging in again."
);
this.processResponse(response, true);
} else {
const command = new UnlockCommand(
this.main.cryptoService,
this.main.stateService,
this.main.cryptoFunctionService,
this.main.apiService,
this.main.logService,
this.main.keyConnectorService,
this.main.environmentService,
this.main.syncService,
this.main.logout
);
const response = await command.run(null, null);
if (!response.success) {
this.processResponse(response, true);
}
}
} else {
this.processResponse(Response.error("Vault is locked."), true);
}
}
}

View File

@ -0,0 +1,334 @@
import * as fs from "fs";
import * as path from "path";
import * as chalk from "chalk";
import * as program from "commander";
import { SendType } from "jslib-common/enums/sendType";
import { Utils } from "jslib-common/misc/utils";
import { Response } from "jslib-node/cli/models/response";
import { Main } from "./bw";
import { GetCommand } from "./commands/get.command";
import { SendCreateCommand } from "./commands/send/create.command";
import { SendDeleteCommand } from "./commands/send/delete.command";
import { SendEditCommand } from "./commands/send/edit.command";
import { SendGetCommand } from "./commands/send/get.command";
import { SendListCommand } from "./commands/send/list.command";
import { SendReceiveCommand } from "./commands/send/receive.command";
import { SendRemovePasswordCommand } from "./commands/send/removePassword.command";
import { SendFileResponse } from "./models/response/sendFileResponse";
import { SendResponse } from "./models/response/sendResponse";
import { SendTextResponse } from "./models/response/sendTextResponse";
import { Program } from "./program";
import { CliUtils } from "./utils";
const writeLn = CliUtils.writeLn;
export class SendProgram extends Program {
constructor(main: Main) {
super(main);
}
async register() {
program.addCommand(this.sendCommand());
// receive is accessible both at `bw receive` and `bw send receive`
program.addCommand(this.receiveCommand());
}
private sendCommand(): program.Command {
return new program.Command("send")
.arguments("<data>")
.description(
"Work with Bitwarden sends. A Send can be quickly created using this command or subcommands can be used to fine-tune the Send",
{
data: "The data to Send. Specify as a filepath with the --file option",
}
)
.option("-f, --file", "Specifies that <data> is a filepath")
.option(
"-d, --deleteInDays <days>",
"The number of days in the future to set deletion date, defaults to 7",
"7"
)
.option("-a, --maxAccessCount <amount>", "The amount of max possible accesses.")
.option("--hidden", "Hide <data> in web by default. Valid only if --file is not set.")
.option(
"-n, --name <name>",
"The name of the Send. Defaults to a guid for text Sends and the filename for files."
)
.option("--notes <notes>", "Notes to add to the Send.")
.option(
"--fullObject",
"Specifies that the full Send object should be returned rather than just the access url."
)
.addCommand(this.listCommand())
.addCommand(this.templateCommand())
.addCommand(this.getCommand())
.addCommand(this.receiveCommand())
.addCommand(this.createCommand())
.addCommand(this.editCommand())
.addCommand(this.removePasswordCommand())
.addCommand(this.deleteCommand())
.action(async (data: string, options: program.OptionValues) => {
const encodedJson = this.makeSendJson(data, options);
let response: Response;
if (encodedJson instanceof Response) {
response = encodedJson;
} else {
response = await this.runCreate(encodedJson, options);
}
this.processResponse(response);
});
}
private receiveCommand(): program.Command {
return new program.Command("receive")
.arguments("<url>")
.description("Access a Bitwarden Send from a url")
.option("--password <password>", "Password needed to access the Send.")
.option("--passwordenv <passwordenv>", "Environment variable storing the Send's password")
.option(
"--passwordfile <passwordfile>",
"Path to a file containing the Sends password as its first line"
)
.option("--obj", "Return the Send's json object rather than the Send's content")
.option("--output <location>", "Specify a file path to save a File-type Send to")
.on("--help", () => {
writeLn("");
writeLn(
"If a password is required, the provided password is used or the user is prompted."
);
writeLn("", true);
})
.action(async (url: string, options: program.OptionValues) => {
const cmd = new SendReceiveCommand(
this.main.apiService,
this.main.cryptoService,
this.main.cryptoFunctionService,
this.main.platformUtilsService,
this.main.environmentService
);
const response = await cmd.run(url, options);
this.processResponse(response);
});
}
private listCommand(): program.Command {
return new program.Command("list")
.description("List all the Sends owned by you")
.on("--help", () => {
writeLn(chalk("This is in the list command"));
})
.action(async (options: program.OptionValues) => {
await this.exitIfLocked();
const cmd = new SendListCommand(
this.main.sendService,
this.main.environmentService,
this.main.searchService
);
const response = await cmd.run(options);
this.processResponse(response);
});
}
private templateCommand(): program.Command {
return new program.Command("template")
.arguments("<object>")
.description("Get json templates for send objects", {
object: "Valid objects are: send, send.text, send.file",
})
.action(async (object) => {
const cmd = new GetCommand(
this.main.cipherService,
this.main.folderService,
this.main.collectionService,
this.main.totpService,
this.main.auditService,
this.main.cryptoService,
this.main.stateService,
this.main.searchService,
this.main.apiService,
this.main.organizationService
);
const response = await cmd.run("template", object, null);
this.processResponse(response);
});
}
private getCommand(): program.Command {
return new program.Command("get")
.arguments("<id>")
.description("Get Sends owned by you.")
.option("--output <output>", "Output directory or filename for attachment.")
.option("--text", "Specifies to return the text content of a Send")
.on("--help", () => {
writeLn("");
writeLn(" Id:");
writeLn("");
writeLn(" Search term or Send's globally unique `id`.");
writeLn("");
writeLn(" If raw output is specified and no output filename or directory is given for");
writeLn(" an attachment query, the attachment content is written to stdout.");
writeLn("");
writeLn(" Examples:");
writeLn("");
writeLn(" bw get send searchText");
writeLn(" bw get send id");
writeLn(" bw get send searchText --text");
writeLn(" bw get send searchText --file");
writeLn(" bw get send searchText --file --output ../Photos/photo.jpg");
writeLn(" bw get send searchText --file --raw");
writeLn("", true);
})
.action(async (id: string, options: program.OptionValues) => {
await this.exitIfLocked();
const cmd = new SendGetCommand(
this.main.sendService,
this.main.environmentService,
this.main.searchService,
this.main.cryptoService
);
const response = await cmd.run(id, options);
this.processResponse(response);
});
}
private createCommand(): program.Command {
return new program.Command("create")
.arguments("[encodedJson]")
.description("create a Send", {
encodedJson: "JSON object to upload. Can also be piped in through stdin.",
})
.option("--file <path>", "file to Send. Can also be specified in parent's JSON.")
.option("--text <text>", "text to Send. Can also be specified in parent's JSON.")
.option("--hidden", "text hidden flag. Valid only with the --text option.")
.option(
"--password <password>",
"optional password to access this Send. Can also be specified in JSON"
)
.on("--help", () => {
writeLn("");
writeLn("Note:");
writeLn(" Options specified in JSON take precedence over command options");
writeLn("", true);
})
.action(
async (
encodedJson: string,
options: program.OptionValues,
args: { parent: program.Command }
) => {
// Work-around to support `--fullObject` option for `send create --fullObject`
// Calling `option('--fullObject', ...)` above won't work due to Commander doesn't like same option
// to be defind on both parent-command and sub-command
const { fullObject = false } = args.parent.opts();
const mergedOptions = {
...options,
fullObject: fullObject,
};
const response = await this.runCreate(encodedJson, mergedOptions);
this.processResponse(response);
}
);
}
private editCommand(): program.Command {
return new program.Command("edit")
.arguments("[encodedJson]")
.description("edit a Send", {
encodedJson:
"Updated JSON object to save. If not provided, encodedJson is read from stdin.",
})
.option("--itemid <itemid>", "Overrides the itemId provided in [encodedJson]")
.on("--help", () => {
writeLn("");
writeLn("Note:");
writeLn(" You cannot update a File-type Send's file. Just delete and remake it");
writeLn("", true);
})
.action(async (encodedJson: string, options: program.OptionValues) => {
await this.exitIfLocked();
const getCmd = new SendGetCommand(
this.main.sendService,
this.main.environmentService,
this.main.searchService,
this.main.cryptoService
);
const cmd = new SendEditCommand(this.main.sendService, this.main.stateService, getCmd);
const response = await cmd.run(encodedJson, options);
this.processResponse(response);
});
}
private deleteCommand(): program.Command {
return new program.Command("delete")
.arguments("<id>")
.description("delete a Send", {
id: "The id of the Send to delete.",
})
.action(async (id: string) => {
await this.exitIfLocked();
const cmd = new SendDeleteCommand(this.main.sendService);
const response = await cmd.run(id);
this.processResponse(response);
});
}
private removePasswordCommand(): program.Command {
return new program.Command("remove-password")
.arguments("<id>")
.description("removes the saved password from a Send.", {
id: "The id of the Send to alter.",
})
.action(async (id: string) => {
await this.exitIfLocked();
const cmd = new SendRemovePasswordCommand(this.main.sendService);
const response = await cmd.run(id);
this.processResponse(response);
});
}
private makeSendJson(data: string, options: program.OptionValues) {
let sendFile = null;
let sendText = null;
let name = Utils.newGuid();
let type = SendType.Text;
if (options.file != null) {
data = path.resolve(data);
if (!fs.existsSync(data)) {
return Response.badRequest("data path does not exist");
}
sendFile = SendFileResponse.template(data);
name = path.basename(data);
type = SendType.File;
} else {
sendText = SendTextResponse.template(data, options.hidden);
}
const template = Utils.assign(SendResponse.template(null, options.deleteInDays), {
name: options.name ?? name,
notes: options.notes,
file: sendFile,
text: sendText,
type: type,
});
return Buffer.from(JSON.stringify(template), "utf8").toString("base64");
}
private async runCreate(encodedJson: string, options: program.OptionValues) {
await this.exitIfLocked();
const cmd = new SendCreateCommand(
this.main.sendService,
this.main.stateService,
this.main.environmentService
);
return await cmd.run(encodedJson, options);
}
}

View File

@ -0,0 +1,20 @@
import * as fs from "fs";
import * as path from "path";
import { I18nService as BaseI18nService } from "jslib-common/services/i18n.service";
export class I18nService extends BaseI18nService {
constructor(systemLanguage: string, localesDirectory: string) {
super(systemLanguage, localesDirectory, (formattedLocale: string) => {
const filePath = path.join(
__dirname,
this.localesDirectory + "/" + formattedLocale + "/messages.json"
);
const localesJson = fs.readFileSync(filePath, "utf8");
const locales = JSON.parse(localesJson.replace(/^\uFEFF/, "")); // strip the BOM
return Promise.resolve(locales);
});
this.supportedTranslationLocales = ["en"];
}
}

View File

@ -0,0 +1,40 @@
import * as lock from "proper-lockfile";
import { OperationOptions } from "retry";
import { LogService } from "jslib-common/abstractions/log.service";
import { Utils } from "jslib-common/misc/utils";
import { LowdbStorageService as LowdbStorageServiceBase } from "jslib-node/services/lowdbStorage.service";
const retries: OperationOptions = {
retries: 50,
minTimeout: 100,
maxTimeout: 250,
factor: 2,
};
export class LowdbStorageService extends LowdbStorageServiceBase {
constructor(
logService: LogService,
defaults?: any,
dir?: string,
allowCache = false,
private requireLock = false
) {
super(logService, defaults, dir, allowCache);
}
protected async lockDbFile<T>(action: () => T): Promise<T> {
if (this.requireLock && !Utils.isNullOrWhitespace(this.dataFilePath)) {
this.logService.info("acquiring db file lock");
return await lock.lock(this.dataFilePath, { retries: retries }).then((release) => {
try {
return action();
} finally {
release();
}
});
} else {
return action();
}
}
}

View File

@ -0,0 +1,103 @@
import { CryptoService } from "jslib-common/abstractions/crypto.service";
import { LogService } from "jslib-common/abstractions/log.service";
import { StorageService } from "jslib-common/abstractions/storage.service";
import { Utils } from "jslib-common/misc/utils";
import { SymmetricCryptoKey } from "jslib-common/models/domain/symmetricCryptoKey";
export class NodeEnvSecureStorageService implements StorageService {
constructor(
private storageService: StorageService,
private logService: LogService,
private cryptoService: () => CryptoService
) {}
async get<T>(key: string): Promise<T> {
const value = await this.storageService.get<string>(this.makeProtectedStorageKey(key));
if (value == null) {
return null;
}
const obj = await this.decrypt(value);
return obj as any;
}
async has(key: string): Promise<boolean> {
return (await this.get(key)) != null;
}
async save(key: string, obj: any): Promise<any> {
if (obj == null) {
return this.remove(key);
}
if (obj !== null && typeof obj !== "string") {
throw new Error("Only string storage is allowed.");
}
const protectedObj = await this.encrypt(obj);
await this.storageService.save(this.makeProtectedStorageKey(key), protectedObj);
}
remove(key: string): Promise<any> {
return this.storageService.remove(this.makeProtectedStorageKey(key));
}
private async encrypt(plainValue: string): Promise<string> {
const sessionKey = this.getSessionKey();
if (sessionKey == null) {
throw new Error("No session key available.");
}
const encValue = await this.cryptoService().encryptToBytes(
Utils.fromB64ToArray(plainValue).buffer,
sessionKey
);
if (encValue == null) {
throw new Error("Value didn't encrypt.");
}
return Utils.fromBufferToB64(encValue.buffer);
}
private async decrypt(encValue: string): Promise<string> {
try {
const sessionKey = this.getSessionKey();
if (sessionKey == null) {
return null;
}
const decValue = await this.cryptoService().decryptFromBytes(
Utils.fromB64ToArray(encValue).buffer,
sessionKey
);
if (decValue == null) {
this.logService.info("Failed to decrypt.");
return null;
}
return Utils.fromBufferToB64(decValue);
} catch (e) {
this.logService.info("Decrypt error.");
return null;
}
}
private getSessionKey() {
try {
if (process.env.BW_SESSION != null) {
const sessionBuffer = Utils.fromB64ToArray(process.env.BW_SESSION).buffer;
if (sessionBuffer != null) {
const sessionKey = new SymmetricCryptoKey(sessionBuffer);
if (sessionBuffer != null) {
return sessionKey;
}
}
}
} catch (e) {
this.logService.info("Session key is invalid.");
}
return null;
}
private makeProtectedStorageKey(key: string) {
return "__PROTECTED__" + key;
}
}

270
apps/cli/src/utils.ts Normal file
View File

@ -0,0 +1,270 @@
import * as fs from "fs";
import * as path from "path";
import * as inquirer from "inquirer";
import * as JSZip from "jszip";
import { LogService } from "jslib-common/abstractions/log.service";
import { NodeUtils } from "jslib-common/misc/nodeUtils";
import { Utils } from "jslib-common/misc/utils";
import { Organization } from "jslib-common/models/domain/organization";
import { CollectionView } from "jslib-common/models/view/collectionView";
import { FolderView } from "jslib-common/models/view/folderView";
import { Response } from "jslib-node/cli/models/response";
import { MessageResponse } from "jslib-node/cli/models/response/messageResponse";
import { FlagName, Flags } from "./flags";
export class CliUtils {
static writeLn(s: string, finalLine = false, error = false) {
const stream = error ? process.stderr : process.stdout;
if (finalLine && (process.platform === "win32" || !stream.isTTY)) {
stream.write(s);
} else {
stream.write(s + "\n");
}
}
static readFile(input: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
let p: string = null;
if (input != null && input !== "") {
const osInput = path.join(input);
if (osInput.indexOf(path.sep) === -1) {
p = path.join(process.cwd(), osInput);
} else {
p = osInput;
}
} else {
reject("You must specify a file path.");
}
fs.readFile(p, "utf8", (err, data) => {
if (err != null) {
reject(err.message);
}
resolve(data);
});
});
}
static extract1PuxContent(input: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
let p: string = null;
if (input != null && input !== "") {
const osInput = path.join(input);
if (osInput.indexOf(path.sep) === -1) {
p = path.join(process.cwd(), osInput);
} else {
p = osInput;
}
} else {
reject("You must specify a file path.");
}
fs.readFile(p, function (err, data) {
if (err) {
reject(err);
}
JSZip.loadAsync(data).then(
(zip) => {
resolve(zip.file("export.data").async("string"));
},
(reason) => {
reject(reason);
}
);
});
});
}
/**
* Save the given data to a file and determine the target file if necessary.
* If output is non-empty, it is used as target filename. Otherwise the target filename is
* built from the current working directory and the given defaultFileName.
*
* @param data to be written to the file.
* @param output file to write to or empty to choose automatically.
* @param defaultFileName to use when no explicit output filename is given.
* @return the chosen output file.
*/
static saveFile(data: string | Buffer, output: string, defaultFileName: string) {
let p: string = null;
let mkdir = false;
if (output != null && output !== "") {
const osOutput = path.join(output);
if (osOutput.indexOf(path.sep) === -1) {
p = path.join(process.cwd(), osOutput);
} else {
mkdir = true;
if (osOutput.endsWith(path.sep)) {
p = path.join(osOutput, defaultFileName);
} else {
p = osOutput;
}
}
} else {
p = path.join(process.cwd(), defaultFileName);
}
p = path.resolve(p);
if (mkdir) {
const dir = p.substring(0, p.lastIndexOf(path.sep));
if (!fs.existsSync(dir)) {
NodeUtils.mkdirpSync(dir, "700");
}
}
return new Promise<string>((resolve, reject) => {
fs.writeFile(p, data, { encoding: "utf8", mode: 0o600 }, (err) => {
if (err != null) {
reject("Cannot save file to " + p);
}
resolve(p);
});
});
}
/**
* Process the given data and write it to a file if possible. If the user requested RAW output and
* no output name is given, the file is directly written to stdout. The resulting Response contains
* an otherwise empty message then to prevent writing other information to stdout.
*
* If an output is given or no RAW output is requested, the rules from [saveFile] apply.
*
* @param data to be written to the file or stdout.
* @param output file to write to or empty to choose automatically.
* @param defaultFileName to use when no explicit output filename is given.
* @return an empty [Response] if written to stdout or a [Response] with the chosen output file otherwise.
*/
static async saveResultToFile(data: string | Buffer, output: string, defaultFileName: string) {
if ((output == null || output === "") && process.env.BW_RAW === "true") {
// No output is given and the user expects raw output. Since the command result is about content,
// we directly return the command result to stdout (and suppress further messages).
process.stdout.write(data);
return Response.success();
}
const filePath = await this.saveFile(data, output, defaultFileName);
const res = new MessageResponse("Saved " + filePath, null);
res.raw = filePath;
return Response.success(res);
}
static readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let input = "";
if (process.stdin.isTTY) {
resolve(input);
return;
}
process.stdin.setEncoding("utf8");
process.stdin.on("readable", () => {
// eslint-disable-next-line
while (true) {
const chunk = process.stdin.read();
if (chunk == null) {
break;
}
input += chunk;
}
});
process.stdin.on("end", () => {
resolve(input);
});
});
}
static searchFolders(folders: FolderView[], search: string) {
search = search.toLowerCase();
return folders.filter((f) => {
if (f.name != null && f.name.toLowerCase().indexOf(search) > -1) {
return true;
}
return false;
});
}
static searchCollections(collections: CollectionView[], search: string) {
search = search.toLowerCase();
return collections.filter((c) => {
if (c.name != null && c.name.toLowerCase().indexOf(search) > -1) {
return true;
}
return false;
});
}
static searchOrganizations(organizations: Organization[], search: string) {
search = search.toLowerCase();
return organizations.filter((o) => {
if (o.name != null && o.name.toLowerCase().indexOf(search) > -1) {
return true;
}
return false;
});
}
/**
* Gets a password from all available sources. In order of priority these are:
* * passwordfile
* * passwordenv
* * user interaction
*
* Returns password string if successful, Response if not.
*/
static async getPassword(
password: string,
options: { passwordFile?: string; passwordEnv?: string },
logService?: LogService
): Promise<string | Response> {
if (Utils.isNullOrEmpty(password)) {
if (options?.passwordFile) {
password = await NodeUtils.readFirstLine(options.passwordFile);
} else if (options?.passwordEnv) {
if (process.env[options.passwordEnv]) {
password = process.env[options.passwordEnv];
} else if (logService) {
logService.warning(`Warning: Provided passwordenv ${options.passwordEnv} is not set`);
}
}
}
if (Utils.isNullOrEmpty(password)) {
if (process.env.BW_NOINTERACTION !== "true") {
const answer: inquirer.Answers = await inquirer.createPromptModule({
output: process.stderr,
})({
type: "password",
name: "password",
message: "Master password:",
});
password = answer.password;
} else {
return Response.badRequest(
"Master password is required. Try again in interactive mode or provide a password file or environment variable."
);
}
}
return password;
}
static convertBooleanOption(optionValue: any) {
return optionValue || optionValue === "" ? true : false;
}
static flagEnabled(flag: FlagName) {
return this.flags[flag] == null || this.flags[flag];
}
private static get flags(): Flags {
const envFlags = process.env.FLAGS;
if (typeof envFlags === "string") {
return JSON.parse(envFlags) as Flags;
} else {
return envFlags as Flags;
}
}
}

View File

@ -0,0 +1,486 @@
import * as program from "commander";
import { Response } from "jslib-node/cli/models/response";
import { Main } from "./bw";
import { ConfirmCommand } from "./commands/confirm.command";
import { CreateCommand } from "./commands/create.command";
import { DeleteCommand } from "./commands/delete.command";
import { EditCommand } from "./commands/edit.command";
import { ExportCommand } from "./commands/export.command";
import { GetCommand } from "./commands/get.command";
import { ImportCommand } from "./commands/import.command";
import { ListCommand } from "./commands/list.command";
import { RestoreCommand } from "./commands/restore.command";
import { ShareCommand } from "./commands/share.command";
import { Program } from "./program";
import { CliUtils } from "./utils";
const writeLn = CliUtils.writeLn;
export class VaultProgram extends Program {
constructor(protected main: Main) {
super(main);
}
async register() {
program
.addCommand(this.listCommand())
.addCommand(this.getCommand())
.addCommand(this.createCommand())
.addCommand(this.editCommand())
.addCommand(this.deleteCommand())
.addCommand(this.restoreCommand())
.addCommand(this.shareCommand("move", false))
.addCommand(this.confirmCommand())
.addCommand(this.importCommand())
.addCommand(this.exportCommand())
.addCommand(this.shareCommand("share", true));
}
private validateObject(requestedObject: string, validObjects: string[]): boolean {
let success = true;
if (!validObjects.includes(requestedObject)) {
success = false;
this.processResponse(
Response.badRequest(
'Unknown object "' +
requestedObject +
'". Allowed objects are ' +
validObjects.join(", ") +
"."
)
);
}
return success;
}
private listCommand(): program.Command {
const listObjects = [
"items",
"folders",
"collections",
"org-collections",
"org-members",
"organizations",
];
return new program.Command("list")
.arguments("<object>")
.description("List an array of objects from the vault.", {
object: "Valid objects are: " + listObjects.join(", "),
})
.option("--search <search>", "Perform a search on the listed objects.")
.option("--url <url>", "Filter items of type login with a url-match search.")
.option("--folderid <folderid>", "Filter items by folder id.")
.option("--collectionid <collectionid>", "Filter items by collection id.")
.option(
"--organizationid <organizationid>",
"Filter items or collections by organization id."
)
.option("--trash", "Filter items that are deleted and in the trash.")
.on("--help", () => {
writeLn("\n Notes:");
writeLn("");
writeLn(" Combining search with a filter performs a logical AND operation.");
writeLn("");
writeLn(" Combining multiple filters performs a logical OR operation.");
writeLn("");
writeLn(" Examples:");
writeLn("");
writeLn(" bw list items");
writeLn(" bw list items --folderid 60556c31-e649-4b5d-8daf-fc1c391a1bf2");
writeLn(
" bw list items --search google --folderid 60556c31-e649-4b5d-8daf-fc1c391a1bf2"
);
writeLn(" bw list items --url https://google.com");
writeLn(" bw list items --folderid null");
writeLn(" bw list items --organizationid notnull");
writeLn(
" bw list items --folderid 60556c31-e649-4b5d-8daf-fc1c391a1bf2 --organizationid notnull"
);
writeLn(" bw list items --trash");
writeLn(" bw list folders --search email");
writeLn(" bw list org-members --organizationid 60556c31-e649-4b5d-8daf-fc1c391a1bf2");
writeLn("", true);
})
.action(async (object, cmd) => {
if (!this.validateObject(object, listObjects)) {
return;
}
await this.exitIfLocked();
const command = new ListCommand(
this.main.cipherService,
this.main.folderService,
this.main.collectionService,
this.main.organizationService,
this.main.searchService,
this.main.apiService
);
const response = await command.run(object, cmd);
this.processResponse(response);
});
}
private getCommand(): program.Command {
const getObjects = [
"item",
"username",
"password",
"uri",
"totp",
"notes",
"exposed",
"attachment",
"folder",
"collection",
"org-collection",
"organization",
"template",
"fingerprint",
"send",
];
return new program.Command("get")
.arguments("<object> <id>")
.description("Get an object from the vault.", {
object: "Valid objects are: " + getObjects.join(", "),
id: "Search term or object's globally unique `id`.",
})
.option("--itemid <itemid>", "Attachment's item id.")
.option("--output <output>", "Output directory or filename for attachment.")
.option("--organizationid <organizationid>", "Organization id for an organization object.")
.on("--help", () => {
writeLn("\n If raw output is specified and no output filename or directory is given for");
writeLn(" an attachment query, the attachment content is written to stdout.");
writeLn("");
writeLn(" Examples:");
writeLn("");
writeLn(" bw get item 99ee88d2-6046-4ea7-92c2-acac464b1412");
writeLn(" bw get password https://google.com");
writeLn(" bw get totp google.com");
writeLn(" bw get notes google.com");
writeLn(" bw get exposed yahoo.com");
writeLn(
" bw get attachment b857igwl1dzrs2 --itemid 99ee88d2-6046-4ea7-92c2-acac464b1412 " +
"--output ./photo.jpg"
);
writeLn(
" bw get attachment photo.jpg --itemid 99ee88d2-6046-4ea7-92c2-acac464b1412 --raw"
);
writeLn(" bw get folder email");
writeLn(" bw get template folder");
writeLn("", true);
})
.action(async (object, id, cmd) => {
if (!this.validateObject(object, getObjects)) {
return;
}
await this.exitIfLocked();
const command = new GetCommand(
this.main.cipherService,
this.main.folderService,
this.main.collectionService,
this.main.totpService,
this.main.auditService,
this.main.cryptoService,
this.main.stateService,
this.main.searchService,
this.main.apiService,
this.main.organizationService
);
const response = await command.run(object, id, cmd);
this.processResponse(response);
});
}
private createCommand() {
const createObjects = ["item", "attachment", "folder", "org-collection"];
return new program.Command("create")
.arguments("<object> [encodedJson]")
.description("Create an object in the vault.", {
object: "Valid objects are: " + createObjects.join(", "),
encodedJson: "Encoded json of the object to create. Can also be piped into stdin.",
})
.option("--file <file>", "Path to file for attachment.")
.option("--itemid <itemid>", "Attachment's item id.")
.option("--organizationid <organizationid>", "Organization id for an organization object.")
.on("--help", () => {
writeLn("\n Examples:");
writeLn("");
writeLn(" bw create folder eyJuYW1lIjoiTXkgRm9sZGVyIn0K");
writeLn(" echo 'eyJuYW1lIjoiTXkgRm9sZGVyIn0K' | bw create folder");
writeLn(
" bw create attachment --file ./myfile.csv " +
"--itemid 16b15b89-65b3-4639-ad2a-95052a6d8f66"
);
writeLn("", true);
})
.action(async (object, encodedJson, cmd) => {
if (!this.validateObject(object, createObjects)) {
return;
}
await this.exitIfLocked();
const command = new CreateCommand(
this.main.cipherService,
this.main.folderService,
this.main.stateService,
this.main.cryptoService,
this.main.apiService
);
const response = await command.run(object, encodedJson, cmd);
this.processResponse(response);
});
}
private editCommand(): program.Command {
const editObjects = ["item", "item-collections", "folder", "org-collection"];
return new program.Command("edit")
.arguments("<object> <id> [encodedJson]")
.description("Edit an object from the vault.", {
object: "Valid objects are: " + editObjects.join(", "),
id: "Object's globally unique `id`.",
encodedJson: "Encoded json of the object to create. Can also be piped into stdin.",
})
.option("--organizationid <organizationid>", "Organization id for an organization object.")
.on("--help", () => {
writeLn("\n Examples:");
writeLn("");
writeLn(
" bw edit folder 5cdfbd80-d99f-409b-915b-f4c5d0241b02 eyJuYW1lIjoiTXkgRm9sZGVyMiJ9Cg=="
);
writeLn(
" echo 'eyJuYW1lIjoiTXkgRm9sZGVyMiJ9Cg==' | " +
"bw edit folder 5cdfbd80-d99f-409b-915b-f4c5d0241b02"
);
writeLn(
" bw edit item-collections 78307355-fd25-416b-88b8-b33fd0e88c82 " +
"WyI5NzQwNTNkMC0zYjMzLTRiOTgtODg2ZS1mZWNmNWM4ZGJhOTYiXQ=="
);
writeLn("", true);
})
.action(async (object, id, encodedJson, cmd) => {
if (!this.validateObject(object, editObjects)) {
return;
}
await this.exitIfLocked();
const command = new EditCommand(
this.main.cipherService,
this.main.folderService,
this.main.cryptoService,
this.main.apiService
);
const response = await command.run(object, id, encodedJson, cmd);
this.processResponse(response);
});
}
private deleteCommand(): program.Command {
const deleteObjects = ["item", "attachment", "folder", "org-collection"];
return new program.Command("delete")
.arguments("<object> <id>")
.description("Delete an object from the vault.", {
object: "Valid objects are: " + deleteObjects.join(", "),
id: "Object's globally unique `id`.",
})
.option("--itemid <itemid>", "Attachment's item id.")
.option("--organizationid <organizationid>", "Organization id for an organization object.")
.option(
"-p, --permanent",
"Permanently deletes the item instead of soft-deleting it (item only)."
)
.on("--help", () => {
writeLn("\n Examples:");
writeLn("");
writeLn(" bw delete item 7063feab-4b10-472e-b64c-785e2b870b92");
writeLn(" bw delete item 89c21cd2-fab0-4f69-8c6e-ab8a0168f69a --permanent");
writeLn(" bw delete folder 5cdfbd80-d99f-409b-915b-f4c5d0241b02");
writeLn(
" bw delete attachment b857igwl1dzrs2 --itemid 310d5ffd-e9a2-4451-af87-ea054dce0f78"
);
writeLn("", true);
})
.action(async (object, id, cmd) => {
if (!this.validateObject(object, deleteObjects)) {
return;
}
await this.exitIfLocked();
const command = new DeleteCommand(
this.main.cipherService,
this.main.folderService,
this.main.stateService,
this.main.apiService
);
const response = await command.run(object, id, cmd);
this.processResponse(response);
});
}
private restoreCommand(): program.Command {
const restoreObjects = ["item"];
return new program.Command("restore")
.arguments("<object> <id>")
.description("Restores an object from the trash.", {
object: "Valid objects are: " + restoreObjects.join(", "),
id: "Object's globally unique `id`.",
})
.on("--help", () => {
writeLn("\n Examples:");
writeLn("");
writeLn(" bw restore item 7063feab-4b10-472e-b64c-785e2b870b92");
writeLn("", true);
})
.action(async (object, id, cmd) => {
if (!this.validateObject(object, restoreObjects)) {
return;
}
await this.exitIfLocked();
const command = new RestoreCommand(this.main.cipherService);
const response = await command.run(object, id);
this.processResponse(response);
});
}
private shareCommand(commandName: string, deprecated: boolean): program.Command {
return new program.Command(commandName)
.arguments("<id> <organizationId> [encodedJson]")
.description((deprecated ? "--DEPRECATED-- " : "") + "Move an item to an organization.", {
id: "Object's globally unique `id`.",
organizationId: "Organization's globally unique `id`.",
encodedJson: "Encoded json of an array of collection ids. Can also be piped into stdin.",
})
.on("--help", () => {
writeLn("\n Examples:");
writeLn("");
writeLn(
" bw " +
commandName +
" 4af958ce-96a7-45d9-beed-1e70fabaa27a " +
"6d82949b-b44d-468a-adae-3f3bacb0ea32 WyI5NzQwNTNkMC0zYjMzLTRiOTgtODg2ZS1mZWNmNWM4ZGJhOTYiXQ=="
);
writeLn(
" echo '[\"974053d0-3b33-4b98-886e-fecf5c8dba96\"]' | bw encode | " +
"bw " +
commandName +
" 4af958ce-96a7-45d9-beed-1e70fabaa27a 6d82949b-b44d-468a-adae-3f3bacb0ea32"
);
if (deprecated) {
writeLn("");
writeLn('--DEPRECATED See "bw move" for the current implementation--');
}
writeLn("", true);
})
.action(async (id, organizationId, encodedJson, cmd) => {
await this.exitIfLocked();
const command = new ShareCommand(this.main.cipherService);
const response = await command.run(id, organizationId, encodedJson);
this.processResponse(response);
});
}
private confirmCommand(): program.Command {
const confirmObjects = ["org-member"];
return new program.Command("confirm")
.arguments("<object> <id>")
.description("Confirm an object to the organization.", {
object: "Valid objects are: " + confirmObjects.join(", "),
id: "Object's globally unique `id`.",
})
.option("--organizationid <organizationid>", "Organization id for an organization object.")
.on("--help", () => {
writeLn("\n Examples:");
writeLn("");
writeLn(
" bw confirm org-member 7063feab-4b10-472e-b64c-785e2b870b92 " +
"--organizationid 310d5ffd-e9a2-4451-af87-ea054dce0f78"
);
writeLn("", true);
})
.action(async (object, id, cmd) => {
if (!this.validateObject(object, confirmObjects)) {
return;
}
await this.exitIfLocked();
const command = new ConfirmCommand(this.main.apiService, this.main.cryptoService);
const response = await command.run(object, id, cmd);
this.processResponse(response);
});
}
private importCommand(): program.Command {
return new program.Command("import")
.arguments("[format] [input]")
.description("Import vault data from a file.", {
format: "The format of [input]",
input: "Filepath to data to import",
})
.option("--formats", "List formats")
.option("--organizationid <organizationid>", "ID of the organization to import to.")
.on("--help", () => {
writeLn("\n Examples:");
writeLn("");
writeLn(" bw import --formats");
writeLn(" bw import bitwardencsv ./from/source.csv");
writeLn(" bw import keepass2xml keepass_backup.xml");
writeLn(
" bw import --organizationid cf14adc3-aca5-4573-890a-f6fa231436d9 keepass2xml keepass_backup.xml"
);
})
.action(async (format, filepath, options) => {
await this.exitIfLocked();
const command = new ImportCommand(this.main.importService, this.main.organizationService);
const response = await command.run(format, filepath, options);
this.processResponse(response);
});
}
private exportCommand(): program.Command {
return new program.Command("export")
.description("Export vault data to a CSV or JSON file.", {})
.option("--output <output>", "Output directory or filename.")
.option("--format <format>", "Export file format.")
.option(
"--password [password]",
"Use password to encrypt instead of your Bitwarden account encryption key. Only applies to the encrypted_json format."
)
.option("--organizationid <organizationid>", "Organization id for an organization.")
.on("--help", () => {
writeLn("\n Notes:");
writeLn("");
writeLn(
" Valid formats are `csv`, `json`, and `encrypted_json`. Default format is `csv`."
);
writeLn("");
writeLn(
" If --raw option is specified and no output filename or directory is given, the"
);
writeLn(" result is written to stdout.");
writeLn("");
writeLn(" Examples:");
writeLn("");
writeLn(" bw export");
writeLn(" bw --raw export");
writeLn(" bw export myPassword321");
writeLn(" bw export myPassword321 --format json");
writeLn(" bw export --output ./exp/bw.csv");
writeLn(" bw export myPassword321 --output bw.json --format json");
writeLn(
" bw export myPassword321 --organizationid 7063feab-4b10-472e-b64c-785e2b870b92"
);
writeLn("", true);
})
.action(async (options) => {
await this.exitIfLocked();
const command = new ExportCommand(this.main.exportService, this.main.policyService);
const response = await command.run(options);
this.processResponse(response);
});
}
}

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Do not remove this test for UTF-8: if “Ω” doesnt appear as greek uppercase omega letter enclosed in quotation marks, you should use an editor that supports UTF-8, not this one. -->
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>bitwarden-cli</id>
<version>0.0.0</version>
<packageSourceUrl>https://github.com/bitwarden/cli/tree/master/stores/chocolatey</packageSourceUrl>
<owners>kspearrin</owners>
<title>Bitwarden CLI</title>
<authors>Bitwarden Inc.</authors>
<projectUrl>https://bitwarden.com/</projectUrl>
<iconUrl>https://cdn.rawgit.com/bitwarden/desktop/51dd1341/resources/icon.png</iconUrl>
<copyright>Copyright © 2015-2022 Bitwarden Inc.</copyright>
<projectSourceUrl>https://github.com/bitwarden/cli/</projectSourceUrl>
<docsUrl>https://help.bitwarden.com/article/cli/</docsUrl>
<bugTrackerUrl>https://github.com/bitwarden/cli/issues</bugTrackerUrl>
<releaseNotes>https://github.com/bitwarden/cli/releases</releaseNotes>
<licenseUrl>https://github.com/bitwarden/cli/blob/master/LICENSE.txt</licenseUrl>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<tags>bitwarden password manager cli</tags>
<summary>A secure and free password manager for all of your devices.</summary>
<description>
For CLI documentation, please visit https://help.bitwarden.com/article/cli/
-----------------
Bitwarden is the easiest and safest way to store all of your logins and passwords while conveniently keeping them synced between all of your devices.
Password theft is a serious problem. The websites and apps that you use are under attack every day. Security breaches occur and your passwords are stolen. When you reuse the same passwords across apps and websites hackers can easily access your email, bank, and other important accounts.
Security experts recommend that you use a different, randomly generated password for every account that you create. But how do you manage all those passwords? Bitwarden makes it easy for you to create, store, and access your passwords.
Bitwarden stores all of your logins in an encrypted vault that syncs across all of your devices. Since it's fully encrypted before it ever leaves your device, only you have access to your data. Not even the team at Bitwarden can read your data, even if we wanted to. Your data is sealed with AES-256 bit encryption, salted hashing, and PBKDF2 SHA-256.
Bitwarden is 100% open source software. The source code for Bitwarden is hosted on GitHub and everyone is free to review, audit, and contribute to the Bitwarden codebase.
</description>
</metadata>
<files>
<file src="tools\**" target="tools" />
</files>
</package>

View File

@ -0,0 +1,6 @@
VERIFICATION
Verification is intended to assist the Chocolatey moderators and community
in verifying that this package's contents are trustworthy.
This package is published by the Bitwarden project itself. Any binaries will
be identical to other package types published by the project.

View File

@ -0,0 +1,17 @@
name: bw
version: __version__
summary: Bitwarden CLI
description: A secure and free password manager for all of your devices.
confinement: strict
base: core18
apps:
bw:
command: bw
plugs: [network, home, network-bind]
parts:
bw:
plugin: dump
source: ./bw-linux-$SNAPCRAFT_PROJECT_VERSION.zip
override-build: |
chmod +x bw
snapcraftctl build

20
apps/cli/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"pretty": true,
"moduleResolution": "node",
"target": "ES2016",
"module": "es6",
"noImplicitAny": true,
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowJs": true,
"sourceMap": true,
"baseUrl": ".",
"paths": {
"jslib-common/*": ["jslib/common/src/*"],
"jslib-node/*": ["jslib/node/src/*"]
}
},
"include": ["src"]
}

View File

@ -0,0 +1,76 @@
const path = require("path");
const webpack = require("webpack");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const nodeExternals = require("webpack-node-externals");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
const config = require("./config/config");
if (process.env.NODE_ENV == null) {
process.env.NODE_ENV = "development";
}
const ENV = (process.env.ENV = process.env.NODE_ENV);
const envConfig = config.load(ENV);
config.log(envConfig);
const moduleRules = [
{
test: /\.ts$/,
use: "ts-loader",
exclude: path.resolve(__dirname, "node_modules"),
},
];
const plugins = [
new CleanWebpackPlugin(),
new CopyWebpackPlugin({
patterns: [{ from: "./src/locales", to: "locales" }],
}),
new webpack.DefinePlugin({
"process.env.BWCLI_ENV": JSON.stringify(ENV),
}),
new webpack.BannerPlugin({
banner: "#!/usr/bin/env node",
raw: true,
}),
new webpack.IgnorePlugin({
resourceRegExp: /^encoding$/,
contextRegExp: /node-fetch/,
}),
new webpack.EnvironmentPlugin({
BWCLI_ENV: ENV,
FLAGS: envConfig.flags,
}),
];
const webpackConfig = {
mode: ENV,
target: "node",
devtool: ENV === "development" ? "eval-source-map" : "source-map",
node: {
__dirname: false,
__filename: false,
},
entry: {
bw: "./src/bw.ts",
},
optimization: {
minimize: false,
},
resolve: {
extensions: [".ts", ".js"],
symlinks: false,
modules: [path.resolve("node_modules")],
plugins: [new TsconfigPathsPlugin({ configFile: "./tsconfig.json" })],
},
output: {
filename: "[name].js",
path: path.resolve(__dirname, "build"),
},
module: { rules: moduleRules },
plugins: plugins,
externals: [nodeExternals()],
};
module.exports = webpackConfig;