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 3e4aa8e476)

* 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 <gibson.matt10@gmail.com>
Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
This commit is contained in:
Matt Gibson 2022-02-23 15:47:32 -06:00 committed by GitHub
parent 8e65d3e8d2
commit 323c3ee04a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 168 additions and 108 deletions

View File

@ -1,6 +1,7 @@
# Build directories
build
dist
coverage
jslib

11
.vscode/launch.json vendored
View File

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

10
.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}/*"
}
}
}

2
jslib

@ -1 +1 @@
Subproject commit a6092916d80424b8bf4d34e321a0b58f15c7519d
Subproject commit 78b5f1504208931e17dbfd447331447b6fc4ca1f

View File

@ -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<Response> {
async run(options: program.OptionValues): Promise<Response> {
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<Response> {
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;
}
}

View File

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

@ -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<string, any>) {
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();

View File

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

View File

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

View File

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