Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-11721] [BEEEP] Add initial password-protected zip export support #10926

Open
wants to merge 12 commits into
base: auth/beeep/zip-export
Choose a base branch
from
5 changes: 5 additions & 0 deletions apps/browser/test.setup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import "jest-preset-angular/setup-jest";
import { TransformStream, WritableStream, ReadableStream } from "node:stream/web";
(globalThis as any).TransformStream = TransformStream;
(globalThis as any).WritableStream = WritableStream;
(globalThis as any).ReadableStream = ReadableStream;

import { addCustomMatchers } from "@bitwarden/common/spec";

addCustomMatchers();
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/test.setup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import "jest-preset-angular/setup-jest";

import { TransformStream, WritableStream, ReadableStream } from "node:stream/web";
(globalThis as any).TransformStream = TransformStream;
(globalThis as any).WritableStream = WritableStream;
(globalThis as any).ReadableStream = ReadableStream;

Object.defineProperty(window, "CSS", { value: null });
Object.defineProperty(window, "getComputedStyle", {
value: () => {
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -1471,6 +1471,12 @@
"passwordProtectedOptionDescription": {
"message": "Set a file password to encrypt the export and import it to any Bitwarden account using the password for decryption."
},
"zipPasswordProtectedOptionTitle": {
"message": "Encrypt exported zip archive"
},
"zipPasswordProtectedOptionDescription": {
"message": "Set a file password to encrypt the zip export and import it to any Bitwarden account using the password for decryption."
},
"exportTypeHeading": {
"message": "Export type"
},
Expand Down
5 changes: 5 additions & 0 deletions apps/web/test.setup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import "jest-preset-angular/setup-jest";

import { TransformStream, WritableStream, ReadableStream } from "node:stream/web";
(globalThis as any).TransformStream = TransformStream;
(globalThis as any).WritableStream = WritableStream;
(globalThis as any).ReadableStream = ReadableStream;

Object.defineProperty(window, "CSS", { value: null });
Object.defineProperty(window, "getComputedStyle", {
value: () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ module.exports = {
testMatch: ["**/+(*.)+(spec).+(ts)"],
preset: "ts-jest",
testEnvironment: "jsdom",
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
prefix: "<rootDir>/../../../",
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ import { ExportFormat } from "./vault-export.service.abstraction";

export abstract class IndividualVaultExportServiceAbstraction {
getExport: (format: ExportFormat) => Promise<string | Blob>;
getPasswordProtectedExport: (password: string) => Promise<string>;
getPasswordProtectedExport: (format: ExportFormat, password: string) => Promise<string | Blob>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,10 @@ describe("VaultExportService", () => {
jest.spyOn(Utils, "fromBufferToB64").mockReturnValue(salt);
cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews.slice(0, 1));

exportString = await exportService.getPasswordProtectedExport(password);
exportString = (await exportService.getPasswordProtectedExport(
"encrypted_json",
password,
)) as string;
exportObject = JSON.parse(exportString);
});

Expand All @@ -313,15 +316,21 @@ describe("VaultExportService", () => {

it("has a mac property", async () => {
encryptService.encrypt.mockResolvedValue(mac);
exportString = await exportService.getPasswordProtectedExport(password);
exportString = (await exportService.getPasswordProtectedExport(
"encrypted_json",
password,
)) as string;
exportObject = JSON.parse(exportString);

expect(exportObject.encKeyValidation_DO_NOT_EDIT).toEqual(mac.encryptedString);
});

it("has data property", async () => {
encryptService.encrypt.mockResolvedValue(data);
exportString = await exportService.getPasswordProtectedExport(password);
exportString = (await exportService.getPasswordProtectedExport(
"encrypted_json",
password,
)) as string;
exportObject = JSON.parse(exportString);

expect(exportObject.data).toEqual(data.encryptedString);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as JSZip from "jszip";
import { Uint8ArrayWriter, ZipWriter, Uint8ArrayReader } from "@zip.js/zip.js";
import * as papa from "papaparse";
import { firstValueFrom, map } from "rxjs";

Expand Down Expand Up @@ -50,32 +50,36 @@
if (format === "encrypted_json") {
return this.getEncryptedExport();
} else if (format === "zip") {
return this.getDecryptedExportZip();
return this.getExportZip(null);
}
return this.getDecryptedExport(format);
}

async getPasswordProtectedExport(password: string): Promise<string> {
const clearText = (await this.getExport("json")) as string;
return this.buildPasswordExport(clearText, password);
async getPasswordProtectedExport(format: ExportFormat, password: string): Promise<string | Blob> {
if (format == "encrypted_json") {
const clearText = (await this.getExport("json")) as string;
return await this.buildPasswordExport(clearText, password);
} else if (format === "zip") {
return await this.getExportZip(password);

Check warning on line 63 in libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts

View check run for this annotation

Codecov / codecov/patch

libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts#L63

Added line #L63 was not covered by tests
} else {
throw new Error("CSV does not support password protected export");

Check warning on line 65 in libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts

View check run for this annotation

Codecov / codecov/patch

libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts#L65

Added line #L65 was not covered by tests
}
}

async getDecryptedExportZip(): Promise<Blob> {
const zip = new JSZip();
async getExportZip(password?: string): Promise<Blob> {
const blobWriter = new Uint8ArrayWriter();
const zipWriter = new ZipWriter(blobWriter, { bufferedWrite: false, password });

// ciphers
const dataJson = await this.getDecryptedExport("json");
zip.file("data.json", dataJson);

const attachmentsFolder = zip.folder("attachments");
const dataJsonUint8Array = Utils.fromByteStringToArray(dataJson);
await zipWriter.add("data.json", new Uint8ArrayReader(dataJsonUint8Array));

// attachments
for (const cipher of await this.cipherService.getAllDecrypted()) {
if (!cipher.attachments || cipher.attachments.length === 0 || cipher.deletedDate != null) {
continue;
}

const cipherFolder = attachmentsFolder.folder(cipher.id);
for (const attachment of cipher.attachments) {
const response = await fetch(new Request(attachment.url, { cache: "no-store" }));
const encBuf = await EncArrayBuffer.fromResponse(response);
Expand All @@ -84,11 +88,17 @@
? attachment.key
: await this.cryptoService.getOrgKey(cipher.organizationId);
const decBuf = await this.encryptService.decryptToBytes(encBuf, key);
cipherFolder.file(attachment.fileName, decBuf);
await zipWriter.add(
`attachments/${cipher.id}/${attachment.fileName}`,
new Uint8ArrayReader(decBuf),
);
}
}

return zip.generateAsync({ type: "blob" });
await zipWriter.close();
const zipFileArray = await blobWriter.getData();

return new Blob([zipFileArray], { type: "application/zip" });
}

private async getDecryptedExport(format: "json" | "csv"): Promise<string> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { TransformStream } from "node:stream/web";
globalThis.TransformStream = TransformStream;

import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";

Expand Down Expand Up @@ -245,7 +248,10 @@ describe("VaultExportService", () => {
jest.spyOn(Utils, "fromBufferToB64").mockReturnValue(salt);
cipherService.getAllDecrypted.mockResolvedValue(UserCipherViews.slice(0, 1));

exportString = await exportService.getPasswordProtectedExport(password);
exportString = (await exportService.getPasswordProtectedExport(
"encrypted_json",
password,
)) as string;
exportObject = JSON.parse(exportString);
});

Expand All @@ -271,15 +277,21 @@ describe("VaultExportService", () => {

it("has a mac property", async () => {
encryptService.encrypt.mockResolvedValue(mac);
exportString = await exportService.getPasswordProtectedExport(password);
exportString = (await exportService.getPasswordProtectedExport(
"encrypted_json",
password,
)) as string;
exportObject = JSON.parse(exportString);

expect(exportObject.encKeyValidation_DO_NOT_EDIT).toEqual(mac.encryptedString);
});

it("has data property", async () => {
encryptService.encrypt.mockResolvedValue(data);
exportString = await exportService.getPasswordProtectedExport(password);
exportString = (await exportService.getPasswordProtectedExport(
"encrypted_json",
password,
)) as string;
exportObject = JSON.parse(exportString);

expect(exportObject.data).toEqual(data.encryptedString);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
throw new Error("CSV does not support password protected export");
}

return this.individualVaultExportService.getPasswordProtectedExport(password);
return this.individualVaultExportService.getPasswordProtectedExport(format, password);

Check warning on line 20 in libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts

View check run for this annotation

Codecov / codecov/patch

libs/tools/export/vault-export/vault-export-core/src/services/vault-export.service.ts#L20

Added line #L20 was not covered by tests
}
return this.individualVaultExportService.getExport(format);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { TransformStream, WritableStream, ReadableStream } from "node:stream/web";
(globalThis as any).TransformStream = TransformStream;
(globalThis as any).WritableStream = WritableStream;
(globalThis as any).ReadableStream = ReadableStream;
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,53 @@
</bit-form-field>
</ng-container>
</ng-container>

<ng-container *ngIf="format === 'zip'">
<bit-form-control>
<bit-label>{{ "zipPasswordProtectedOptionTitle" | i18n }}</bit-label>
<input type="checkbox" bitCheckbox formControlName="passwordProtected" />
<bit-hint>{{ "zipPasswordProtectedOptionDescription" | i18n }}</bit-hint>
</bit-form-control>
<ng-container *ngIf="zipPasswordProtected">
<div class="tw-mb-3" *ngIf="zipPasswordProtected">
<bit-form-field>
<bit-label>{{ "filePassword" | i18n }}</bit-label>
<input
bitInput
type="password"
id="filePassword"
formControlName="filePassword"
name="password"
/>
<button
type="button"
bitSuffix
bitIconButton
bitPasswordInputToggle
[(toggled)]="showFilePassword"
></button>
<bit-hint>{{ "exportPasswordDescription" | i18n }}</bit-hint>
</bit-form-field>
<tools-password-strength [password]="filePassword" [showText]="true">
</tools-password-strength>
</div>
<bit-form-field>
<bit-label>{{ "confirmFilePassword" | i18n }}</bit-label>
<input
bitInput
type="password"
id="confirmFilePassword"
formControlName="confirmFilePassword"
name="confirmFilePassword"
/>
<button
type="button"
bitSuffix
bitIconButton
bitPasswordInputToggle
[(toggled)]="showFilePassword"
></button>
</bit-form-field>
</ng-container>
</ng-container>
</form>
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
BitSubmitDirective,
ButtonModule,
CalloutModule,
CheckboxModule,
DialogService,
FormFieldModule,
IconButtonModule,
Expand All @@ -52,6 +53,7 @@ import { ExportScopeCalloutComponent } from "./export-scope-callout.component";
standalone: true,
imports: [
CommonModule,
CheckboxModule,
ReactiveFormsModule,
JslibModule,
FormFieldModule,
Expand Down Expand Up @@ -138,6 +140,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
},
],
format: ["json", Validators.required],
passwordProtected: [true],
secret: [""],
filePassword: ["", Validators.required],
confirmFilePassword: ["", Validators.required],
Expand Down Expand Up @@ -188,6 +191,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
merge(
this.exportForm.get("format").valueChanges,
this.exportForm.get("fileEncryptionType").valueChanges,
this.exportForm.get("passwordProtected").valueChanges,
)
.pipe(startWith(0), takeUntil(this.destroy$))
.subscribe(() => this.adjustValidators());
Expand All @@ -203,6 +207,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
return;
} else {
this.formatOptions.push({ name: ".zip (With Attachments)", value: "zip" });
this.exportForm.setValue({ format: "zip" });
}

this.organizations$ = combineLatest({
Expand Down Expand Up @@ -259,6 +264,10 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
);
}

get zipPasswordProtected() {
return this.exportForm.get("passwordProtected").value;
}

protected async doExport() {
try {
const data = await this.getExportData();
Expand Down Expand Up @@ -323,7 +332,7 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {

private async verifyUser(): Promise<boolean> {
let confirmDescription = "exportWarningDesc";
if (this.isFileEncryptedExport) {
if (this.isFileEncryptedExport || (this.format === "zip" && this.zipPasswordProtected)) {
confirmDescription = "fileEncryptedExportWarningDesc";
} else if (this.isAccountEncryptedExport) {
confirmDescription = "encExportKeyWarningDesc";
Expand Down Expand Up @@ -415,12 +424,16 @@ export class ExportComponent implements OnInit, OnDestroy, AfterViewInit {
this.exportForm.get("confirmFilePassword").reset();
this.exportForm.get("filePassword").reset();

if (this.encryptedFormat && this.fileEncryptionType == EncryptedExportType.FileEncrypted) {
if (
(this.encryptedFormat && this.fileEncryptionType == EncryptedExportType.FileEncrypted) ||
(this.format === "zip" && this.zipPasswordProtected)
) {
this.exportForm.controls.filePassword.enable();
this.exportForm.controls.confirmFilePassword.enable();
} else {
this.exportForm.controls.filePassword.disable();
this.exportForm.controls.confirmFilePassword.disable();
this.exportForm.setValue({ filePassword: "", confirmFilePassword: "" });
}
}

Expand Down
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@
"@microsoft/signalr": "8.0.7",
"@microsoft/signalr-protocol-msgpack": "8.0.7",
"@ng-select/ng-select": "11.2.0",
"@zip.js/zip.js": "^2.7.52",
"argon2": "0.40.1",
"argon2-browser": "1.18.0",
"big-integer": "1.6.52",
Expand Down
Loading