From 323c3ee04a64f4f651088b03a5547c8219f13876 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Wed, 23 Feb 2022 15:47:32 -0600 Subject: [PATCH] Feature/password protected export (#446) * Update jslib * Bumped version to 1.20.0 (#421) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> (cherry picked from commit 3e4aa8e476a70ad63e8d671b83590ca74b22414e) * password protected export * Run Prettier * Add importer to list of known file types * Improve launch.json settings * Turn on import from password protected file * Run prettier * Fix webpack source map path change * Update getPassword helper to use new options class * Prettier * Add client type * Remove master password requirement for export Alter password optional argument to indicating the file should be password protected rather than account protected * update jslib * Handle passwordProtected automagically * Remove passwordproteted type from import command * Update src/utils.ts Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> * Update src/vault.program.ts Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> * Use new util method * remove password protected format * Update jslib * Clarify export command * Run prettier Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Matt Gibson Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com> --- .prettierignore | 1 + .vscode/launch.json | 11 +++++ .vscode/settings.json | 10 ++++ jslib | 2 +- src/commands/export.command.ts | 90 ++++++++++++---------------------- src/commands/import.command.ts | 46 ++++++++++++++--- src/commands/unlock.command.ts | 36 +++----------- src/locales/en/messages.json | 6 +++ src/utils.ts | 50 +++++++++++++++++++ src/vault.program.ts | 24 +++++---- 10 files changed, 168 insertions(+), 108 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.prettierignore b/.prettierignore index 4b29794..02a17b1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,7 @@ # Build directories build dist +coverage jslib diff --git a/.vscode/launch.json b/.vscode/launch.json index 87e069a..ac4d00d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,6 +8,17 @@ "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"] } ] diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4570edf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "debug.javascript.terminalOptions": { + "sourceMapPathOverrides": { + "meteor://💻app/*": "${workspaceFolder}/*", + "webpack:///./~/*": "${workspaceFolder}/node_modules/*", + "webpack://?:*/*": "${workspaceFolder}/*", + "webpack://@bitwarden/cli/*": "${workspaceFolder}/*" + } + } +} diff --git a/jslib b/jslib index a609291..78b5f15 160000 --- a/jslib +++ b/jslib @@ -1 +1 @@ -Subproject commit a6092916d80424b8bf4d34e321a0b58f15c7519d +Subproject commit 78b5f1504208931e17dbfd447331447b6fc4ca1f diff --git a/src/commands/export.command.ts b/src/commands/export.command.ts index 38f66ba..028e734 100644 --- a/src/commands/export.command.ts +++ b/src/commands/export.command.ts @@ -1,29 +1,21 @@ import * as program from "commander"; import * as inquirer from "inquirer"; -import { ExportService } from "jslib-common/abstractions/export.service"; -import { KeyConnectorService } from "jslib-common/abstractions/keyConnector.service"; +import { ExportFormat, ExportService } from "jslib-common/abstractions/export.service"; import { PolicyService } from "jslib-common/abstractions/policy.service"; -import { UserVerificationService } from "jslib-common/abstractions/userVerification.service"; import { Response } from "jslib-node/cli/models/response"; import { PolicyType } from "jslib-common/enums/policyType"; -import { VerificationType } from "jslib-common/enums/verificationType"; import { Utils } from "jslib-common/misc/utils"; import { CliUtils } from "../utils"; export class ExportCommand { - constructor( - private exportService: ExportService, - private policyService: PolicyService, - private keyConnectorService: KeyConnectorService, - private userVerificationService: UserVerificationService - ) {} + constructor(private exportService: ExportService, private policyService: PolicyService) {} - async run(password: string, options: program.OptionValues): Promise { + async run(options: program.OptionValues): Promise { if ( options.organizationid == null && (await this.policyService.policyAppliesToUser(PolicyType.DisablePersonalVaultExport)) @@ -33,44 +25,39 @@ export class ExportCommand { ); } - const canInteract = process.env.BW_NOINTERACTION !== "true"; - if (!canInteract) { - return Response.badRequest( - "User verification is required. Try running this command again in interactive mode." - ); - } + const format = options.format ?? "csv"; - try { - (await this.keyConnectorService.getUsesKeyConnector()) - ? await this.verifyOTP() - : await this.verifyMasterPassword(password); - } catch (e) { - return Response.badRequest(e.message); - } - - let format = options.format; - if (format !== "encrypted_json" && format !== "json") { - 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 = - options.organizationid != null - ? await this.exportService.getOrganizationExport(options.organizationid, format) - : await this.exportService.getExport(format); + 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); } - async saveFile( + 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: string + format: ExportFormat ): Promise { try { const fileName = this.getFileName(format, options.organizationid != null ? "org" : null); @@ -80,7 +67,7 @@ export class ExportCommand { } } - private getFileName(format: string, prefix?: string) { + private getFileName(format: ExportFormat, prefix?: string) { if (format === "encrypted_json") { if (prefix == null) { prefix = "encrypted"; @@ -92,35 +79,22 @@ export class ExportCommand { return this.exportService.getFileName(prefix, format); } - private async verifyMasterPassword(password: string) { - if (password == null || password === "") { + 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: "Master password:", + message: "Export file password:", }); - password = answer.password; + return answer.password as string; } - - await this.userVerificationService.verifyUser({ - type: VerificationType.MasterPassword, - secret: password, - }); - } - - private async verifyOTP() { - await this.userVerificationService.requestOTP(); - const answer: inquirer.Answers = await inquirer.createPromptModule({ output: process.stderr })({ - type: "password", - name: "otp", - message: "A verification code has been emailed to you.\n Verification code:", - }); - - await this.userVerificationService.verifyUser({ - type: VerificationType.OTP, - secret: answer.otp, - }); + return null; } } diff --git a/src/commands/import.command.ts b/src/commands/import.command.ts index f5e84c9..c4cd994 100644 --- a/src/commands/import.command.ts +++ b/src/commands/import.command.ts @@ -1,8 +1,11 @@ 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 { ImportType } from "jslib-common/services/import.service"; +import { Importer } from "jslib-common/importers/importer"; import { Response } from "jslib-node/cli/models/response"; import { MessageResponse } from "jslib-node/cli/models/response/messageResponse"; @@ -63,12 +66,11 @@ export class ImportCommand { return Response.badRequest("Import file was empty."); } - const err = await this.importService.import(importer, contents, organizationId); - if (err != null) { - return Response.badRequest(err.message); + const response = await this.doImport(importer, contents, organizationId); + if (response.success) { + response.data = new MessageResponse("Imported " + filepath, null); } - const res = new MessageResponse("Imported " + filepath, null); - return Response.success(res); + return response; } catch (err) { return Response.badRequest(err); } @@ -86,4 +88,36 @@ export class ImportCommand { res.raw = options; return Response.success(res); } + + private async doImport( + importer: Importer, + contents: string, + organizationId?: string + ): Promise { + 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; + } } diff --git a/src/commands/unlock.command.ts b/src/commands/unlock.command.ts index df4f10c..410262a 100644 --- a/src/commands/unlock.command.ts +++ b/src/commands/unlock.command.ts @@ -1,5 +1,3 @@ -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"; @@ -14,9 +12,9 @@ import { MessageResponse } from "jslib-node/cli/models/response/messageResponse" import { SecretVerificationRequest } from "jslib-common/models/request/secretVerificationRequest"; import { Utils } from "jslib-common/misc/utils"; +import { CliUtils } from "../utils"; import { HashPurpose } from "jslib-common/enums/hashPurpose"; -import { NodeUtils } from "jslib-common/misc/nodeUtils"; import { ConsoleLogService } from "jslib-common/services/consoleLog.service"; import { ConvertToKeyConnectorCommand } from "./convertToKeyConnector.command"; @@ -37,34 +35,12 @@ export class UnlockCommand { async run(password: string, cmdOptions: Record) { const canInteract = process.env.BW_NOINTERACTION !== "true"; const normalizedOptions = new Options(cmdOptions); - if (password == null || password === "") { - if (normalizedOptions?.passwordFile) { - password = await NodeUtils.readFirstLine(normalizedOptions.passwordFile); - } else if (normalizedOptions?.passwordEnv) { - if (process.env[normalizedOptions.passwordEnv]) { - password = process.env[normalizedOptions.passwordEnv]; - } else { - this.logService.warning( - `Warning: Provided passwordenv ${normalizedOptions.passwordEnv} is not set` - ); - } - } - } + const passwordResult = await CliUtils.getPassword(password, normalizedOptions, this.logService); - if (password == null || password === "") { - if (canInteract) { - 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."); - } + if (passwordResult instanceof Response) { + return passwordResult; + } else { + password = passwordResult; } await this.setNewSessionKey(); diff --git a/src/locales/en/messages.json b/src/locales/en/messages.json index b2e3e21..d14e597 100644 --- a/src/locales/en/messages.json +++ b/src/locales/en/messages.json @@ -14,6 +14,12 @@ "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." }, diff --git a/src/utils.ts b/src/utils.ts index 2a82268..27ef5fe 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,6 @@ +import * as program from "commander"; import * as fs from "fs"; +import * as inquirer from "inquirer"; import * as path from "path"; import { Response } from "jslib-node/cli/models/response"; @@ -11,6 +13,9 @@ import { FolderView } from "jslib-common/models/view/folderView"; import { NodeUtils } from "jslib-common/misc/nodeUtils"; import { FlagName, Flags } from "./flags"; +import { LogService } from "jslib-common/abstractions/log.service"; +import { Utils } from "jslib-common/misc/utils"; + export class CliUtils { static writeLn(s: string, finalLine: boolean = false, error: boolean = false) { const stream = error ? process.stderr : process.stdout; @@ -172,6 +177,51 @@ export class CliUtils { }); } + /** + * 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 { + 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; } diff --git a/src/vault.program.ts b/src/vault.program.ts index 54651e8..6c1dcd5 100644 --- a/src/vault.program.ts +++ b/src/vault.program.ts @@ -447,17 +447,20 @@ export class VaultProgram extends Program { private exportCommand(): program.Command { return new program.Command("export") - .arguments("[password]") - .description("Export vault data to a CSV or JSON file.", { - password: "Optional: Your master password.", - }) + .description("Export vault data to a CSV or JSON file.", {}) .option("--output ", "Output directory or filename.") .option("--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 ", "Organization id for an organization.") .on("--help", () => { writeLn("\n Notes:"); writeLn(""); - writeLn(" Valid formats are `csv`, `json`, `encrypted_json`. Default format is `csv`."); + 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" @@ -477,15 +480,10 @@ export class VaultProgram extends Program { ); writeLn("", true); }) - .action(async (password, options) => { + .action(async (options) => { await this.exitIfLocked(); - const command = new ExportCommand( - this.main.exportService, - this.main.policyService, - this.main.keyConnectorService, - this.main.userVerificationService - ); - const response = await command.run(password, options); + const command = new ExportCommand(this.main.exportService, this.main.policyService); + const response = await command.run(options); this.processResponse(response); }); }