From 15f29c5fb15d02a09fd543859be0e6721a86d6c4 Mon Sep 17 00:00:00 2001 From: Daniel James Smith Date: Tue, 15 Aug 2023 20:32:40 +0200 Subject: [PATCH] [PM-3040] [BEEEP] Extend json-export to include passwordhistory and vault item dates (created, updated, deleted) (#5917) * Add password history to json exports Change callout to not mention missing password history any longer * Added item meta dates to json exports Added vault items creation-/revision-/deleted-dates to json exports * Removed unnecessary promises * Add bitwarden-json-export types Define types Use types in vault-export-service Move existing password-protected type to export-types * Use bitwarden-json-export types in bitwarden-json-importer * Clean up passwordHistory if needed * Define and use bitwarden-csv-export-types --- apps/web/src/locales/en/messages.json | 4 +- .../export-scope-callout.component.ts | 2 +- .../common/src/models/export/cipher.export.ts | 35 +++++ .../models/export/password-history.export.ts | 40 +++++ .../vault-export/bitwarden-csv-export-type.ts | 23 +++ .../bitwarden-json-export-types.ts | 51 +++++++ .../bitwarden-password-protected-types.ts | 11 -- .../services/vault-export.service.ts | 37 +++-- libs/importer/src/importers/base-importer.ts | 3 + .../bitwarden/bitwarden-json-importer.ts | 139 +++++++++++------- .../bitwarden-password-protected-importer.ts | 2 +- 11 files changed, 264 insertions(+), 83 deletions(-) create mode 100644 libs/common/src/models/export/password-history.export.ts create mode 100644 libs/exporter/src/vault-export/bitwarden-csv-export-type.ts create mode 100644 libs/exporter/src/vault-export/bitwarden-json-export-types.ts delete mode 100644 libs/exporter/src/vault-export/bitwarden-password-protected-types.ts diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index e15b3f1b17..97ad4adb40 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -5454,8 +5454,8 @@ "exportingOrganizationVaultTitle": { "message": "Exporting organization vault" }, - "exportingPersonalVaultDescription": { - "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated password history or attachments.", + "exportingIndividualVaultDescription": { + "message": "Only the individual vault items associated with $EMAIL$ will be exported. Organization vault items will not be included. Only vault item information will be exported and will not include associated attachments.", "placeholders": { "email": { "content": "$1", diff --git a/libs/angular/src/tools/export/components/export-scope-callout.component.ts b/libs/angular/src/tools/export/components/export-scope-callout.component.ts index 3095cd5e83..a97db1079a 100644 --- a/libs/angular/src/tools/export/components/export-scope-callout.component.ts +++ b/libs/angular/src/tools/export/components/export-scope-callout.component.ts @@ -35,7 +35,7 @@ export class ExportScopeCalloutComponent implements OnInit { } : { title: "exportingPersonalVaultTitle", - description: "exportingPersonalVaultDescription", + description: "exportingIndividualVaultDescription", scopeIdentifier: await this.stateService.getEmail(), }; this.show = true; diff --git a/libs/common/src/models/export/cipher.export.ts b/libs/common/src/models/export/cipher.export.ts index 5bfde78b55..342cf59fd8 100644 --- a/libs/common/src/models/export/cipher.export.ts +++ b/libs/common/src/models/export/cipher.export.ts @@ -8,6 +8,7 @@ import { CardExport } from "./card.export"; import { FieldExport } from "./field.export"; import { IdentityExport } from "./identity.export"; import { LoginExport } from "./login.export"; +import { PasswordHistoryExport } from "./password-history.export"; import { SecureNoteExport } from "./secure-note.export"; export class CipherExport { @@ -26,6 +27,10 @@ export class CipherExport { req.card = null; req.identity = null; req.reprompt = CipherRepromptType.None; + req.passwordHistory = []; + req.creationDate = null; + req.revisionDate = null; + req.deletedDate = null; return req; } @@ -63,6 +68,13 @@ export class CipherExport { break; } + if (req.passwordHistory != null) { + view.passwordHistory = req.passwordHistory.map((ph) => PasswordHistoryExport.toView(ph)); + } + + view.creationDate = req.creationDate; + view.revisionDate = req.revisionDate; + view.deletedDate = req.deletedDate; return view; } @@ -96,6 +108,13 @@ export class CipherExport { break; } + if (req.passwordHistory != null) { + domain.passwordHistory = req.passwordHistory.map((ph) => PasswordHistoryExport.toDomain(ph)); + } + + domain.creationDate = req.creationDate; + domain.revisionDate = req.revisionDate; + domain.deletedDate = req.deletedDate; return domain; } @@ -112,6 +131,10 @@ export class CipherExport { card: CardExport; identity: IdentityExport; reprompt: CipherRepromptType; + passwordHistory: PasswordHistoryExport[] = null; + revisionDate: Date = null; + creationDate: Date = null; + deletedDate: Date = null; // Use build method instead of ctor so that we can control order of JSON stringify for pretty print build(o: CipherView | CipherDomain) { @@ -152,5 +175,17 @@ export class CipherExport { this.identity = new IdentityExport(o.identity); break; } + + if (o.passwordHistory != null) { + if (o instanceof CipherView) { + this.passwordHistory = o.passwordHistory.map((ph) => new PasswordHistoryExport(ph)); + } else { + this.passwordHistory = o.passwordHistory.map((ph) => new PasswordHistoryExport(ph)); + } + } + + this.creationDate = o.creationDate; + this.revisionDate = o.revisionDate; + this.deletedDate = o.deletedDate; } } diff --git a/libs/common/src/models/export/password-history.export.ts b/libs/common/src/models/export/password-history.export.ts new file mode 100644 index 0000000000..0bdbc6697a --- /dev/null +++ b/libs/common/src/models/export/password-history.export.ts @@ -0,0 +1,40 @@ +import { EncString } from "../../platform/models/domain/enc-string"; +import { Password } from "../../vault/models/domain/password"; +import { PasswordHistoryView } from "../../vault/models/view/password-history.view"; + +export class PasswordHistoryExport { + static template(): PasswordHistoryExport { + const req = new PasswordHistoryExport(); + req.password = null; + req.lastUsedDate = null; + return req; + } + + static toView(req: PasswordHistoryExport, view = new PasswordHistoryView()) { + view.password = req.password; + view.lastUsedDate = req.lastUsedDate; + return view; + } + + static toDomain(req: PasswordHistoryExport, domain = new Password()) { + domain.password = req.password != null ? new EncString(req.password) : null; + domain.lastUsedDate = req.lastUsedDate; + return domain; + } + + password: string; + lastUsedDate: Date = null; + + constructor(o?: PasswordHistoryView | Password) { + if (o == null) { + return; + } + + if (o instanceof PasswordHistoryView) { + this.password = o.password; + } else { + this.password = o.password?.encryptedString; + } + this.lastUsedDate = o.lastUsedDate; + } +} diff --git a/libs/exporter/src/vault-export/bitwarden-csv-export-type.ts b/libs/exporter/src/vault-export/bitwarden-csv-export-type.ts new file mode 100644 index 0000000000..30c6bb89bc --- /dev/null +++ b/libs/exporter/src/vault-export/bitwarden-csv-export-type.ts @@ -0,0 +1,23 @@ +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; + +export type BitwardenCsvExportType = { + type: string; + name: string; + notes: string; + fields: string; + reprompt: CipherRepromptType; + // Login props + login_uri: string[]; + login_username: string; + login_password: string; + login_totp: string; + favorite: number | null; +}; + +export type BitwardenCsvIndividualExportType = BitwardenCsvExportType & { + folder: string | null; +}; + +export type BitwardenCsvOrgExportType = BitwardenCsvExportType & { + collections: string[] | null; +}; diff --git a/libs/exporter/src/vault-export/bitwarden-json-export-types.ts b/libs/exporter/src/vault-export/bitwarden-json-export-types.ts new file mode 100644 index 0000000000..ab2bcbb9f1 --- /dev/null +++ b/libs/exporter/src/vault-export/bitwarden-json-export-types.ts @@ -0,0 +1,51 @@ +import { + CipherWithIdExport, + CollectionWithIdExport, + FolderWithIdExport, +} from "@bitwarden/common/models/export"; + +// Base +export type BitwardenJsonExport = { + encrypted: boolean; + items: CipherWithIdExport[]; +}; + +// Decrypted +export type BitwardenUnEncryptedJsonExport = BitwardenJsonExport & { + encrypted: false; +}; + +export type BitwardenUnEncryptedIndividualJsonExport = BitwardenUnEncryptedJsonExport & { + folders: FolderWithIdExport[]; +}; + +export type BitwardenUnEncryptedOrgJsonExport = BitwardenUnEncryptedJsonExport & { + collections: CollectionWithIdExport[]; +}; + +// Account-encrypted +export type BitwardenEncryptedJsonExport = BitwardenJsonExport & { + encrypted: true; + encKeyValidation_DO_NOT_EDIT: string; +}; + +export type BitwardenEncryptedIndividualJsonExport = BitwardenEncryptedJsonExport & { + folders: FolderWithIdExport[]; +}; + +export type BitwardenEncryptedOrgJsonExport = BitwardenEncryptedJsonExport & { + collections: CollectionWithIdExport[]; +}; + +// Password-protected +export type BitwardenPasswordProtectedFileFormat = { + encrypted: boolean; + passwordProtected: boolean; + salt: string; + kdfIterations: number; + kdfMemory?: number; + kdfParallelism?: number; + kdfType: number; + encKeyValidation_DO_NOT_EDIT: string; + data: string; +}; diff --git a/libs/exporter/src/vault-export/bitwarden-password-protected-types.ts b/libs/exporter/src/vault-export/bitwarden-password-protected-types.ts deleted file mode 100644 index 01671c1680..0000000000 --- a/libs/exporter/src/vault-export/bitwarden-password-protected-types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface BitwardenPasswordProtectedFileFormat { - encrypted: boolean; - passwordProtected: boolean; - salt: string; - kdfIterations: number; - kdfMemory?: number; - kdfParallelism?: number; - kdfType: number; - encKeyValidation_DO_NOT_EDIT: string; - data: string; -} diff --git a/libs/exporter/src/vault-export/services/vault-export.service.ts b/libs/exporter/src/vault-export/services/vault-export.service.ts index 06a8b45f58..8609ce0d1c 100644 --- a/libs/exporter/src/vault-export/services/vault-export.service.ts +++ b/libs/exporter/src/vault-export/services/vault-export.service.ts @@ -26,7 +26,18 @@ import { CollectionView } from "@bitwarden/common/vault/models/view/collection.v import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { ExportHelper } from "../../export-helper"; -import { BitwardenPasswordProtectedFileFormat } from "../bitwarden-password-protected-types"; +import { + BitwardenCsvExportType, + BitwardenCsvIndividualExportType, + BitwardenCsvOrgExportType, +} from "../bitwarden-csv-export-type"; +import { + BitwardenEncryptedIndividualJsonExport, + BitwardenEncryptedOrgJsonExport, + BitwardenUnEncryptedIndividualJsonExport, + BitwardenUnEncryptedOrgJsonExport, + BitwardenPasswordProtectedFileFormat, +} from "../bitwarden-json-export-types"; import { ExportFormat, VaultExportServiceAbstraction } from "./vault-export.service.abstraction"; @@ -123,7 +134,7 @@ export class VaultExportService implements VaultExportServiceAbstraction { } }); - const exportCiphers: any[] = []; + const exportCiphers: BitwardenCsvIndividualExportType[] = []; decCiphers.forEach((c) => { // only export logins and secure notes if (c.type !== CipherType.Login && c.type !== CipherType.SecureNote) { @@ -133,7 +144,7 @@ export class VaultExportService implements VaultExportServiceAbstraction { return; } - const cipher: any = {}; + const cipher = {} as BitwardenCsvIndividualExportType; cipher.folder = c.folderId != null && foldersMap.has(c.folderId) ? foldersMap.get(c.folderId).name : null; cipher.favorite = c.favorite ? 1 : null; @@ -143,7 +154,7 @@ export class VaultExportService implements VaultExportServiceAbstraction { return papa.unparse(exportCiphers); } else { - const jsonDoc: any = { + const jsonDoc: BitwardenUnEncryptedIndividualJsonExport = { encrypted: false, folders: [], items: [], @@ -193,7 +204,7 @@ export class VaultExportService implements VaultExportServiceAbstraction { const encKeyValidation = await this.cryptoService.encrypt(Utils.newGuid()); - const jsonDoc: any = { + const jsonDoc: BitwardenEncryptedIndividualJsonExport = { encrypted: true, encKeyValidation_DO_NOT_EDIT: encKeyValidation.encryptedString, folders: [], @@ -269,14 +280,14 @@ export class VaultExportService implements VaultExportServiceAbstraction { collectionsMap.set(c.id, c); }); - const exportCiphers: any[] = []; + const exportCiphers: BitwardenCsvOrgExportType[] = []; decCiphers.forEach((c) => { // only export logins and secure notes if (c.type !== CipherType.Login && c.type !== CipherType.SecureNote) { return; } - const cipher: any = {}; + const cipher = {} as BitwardenCsvOrgExportType; cipher.collections = []; if (c.collectionIds != null) { cipher.collections = c.collectionIds @@ -289,7 +300,7 @@ export class VaultExportService implements VaultExportServiceAbstraction { return papa.unparse(exportCiphers); } else { - const jsonDoc: any = { + const jsonDoc: BitwardenUnEncryptedOrgJsonExport = { encrypted: false, collections: [], items: [], @@ -317,20 +328,17 @@ export class VaultExportService implements VaultExportServiceAbstraction { promises.push( this.apiService.getCollections(organizationId).then((c) => { - const collectionPromises: any = []; if (c != null && c.data != null && c.data.length > 0) { c.data.forEach((r) => { const collection = new Collection(new CollectionData(r as CollectionDetailsResponse)); collections.push(collection); }); } - return Promise.all(collectionPromises); }) ); promises.push( this.apiService.getCiphersOrganization(organizationId).then((c) => { - const cipherPromises: any = []; if (c != null && c.data != null && c.data.length > 0) { c.data .filter((item) => item.deletedDate === null) @@ -339,7 +347,6 @@ export class VaultExportService implements VaultExportServiceAbstraction { ciphers.push(cipher); }); } - return Promise.all(cipherPromises); }) ); @@ -348,7 +355,7 @@ export class VaultExportService implements VaultExportServiceAbstraction { const orgKey = await this.cryptoService.getOrgKey(organizationId); const encKeyValidation = await this.cryptoService.encrypt(Utils.newGuid(), orgKey); - const jsonDoc: any = { + const jsonDoc: BitwardenEncryptedOrgJsonExport = { encrypted: true, encKeyValidation_DO_NOT_EDIT: encKeyValidation.encryptedString, collections: [], @@ -369,7 +376,7 @@ export class VaultExportService implements VaultExportServiceAbstraction { return JSON.stringify(jsonDoc, null, " "); } - private buildCommonCipher(cipher: any, c: CipherView) { + private buildCommonCipher(cipher: BitwardenCsvExportType, c: CipherView): BitwardenCsvExportType { cipher.type = null; cipher.name = c.name; cipher.notes = c.notes; @@ -382,7 +389,7 @@ export class VaultExportService implements VaultExportServiceAbstraction { cipher.login_totp = null; if (c.fields) { - c.fields.forEach((f: any) => { + c.fields.forEach((f) => { if (!cipher.fields) { cipher.fields = ""; } else { diff --git a/libs/importer/src/importers/base-importer.ts b/libs/importer/src/importers/base-importer.ts index 7c3110ad23..676fec4f33 100644 --- a/libs/importer/src/importers/base-importer.ts +++ b/libs/importer/src/importers/base-importer.ts @@ -313,6 +313,9 @@ export abstract class BaseImporter { if (cipher.fields != null && cipher.fields.length === 0) { cipher.fields = null; } + if (cipher.passwordHistory != null && cipher.passwordHistory.length === 0) { + cipher.passwordHistory = null; + } } protected processKvp( diff --git a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts index 0789c7df2e..5c281dc6b7 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts @@ -6,13 +6,21 @@ import { import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { CollectionView } from "@bitwarden/common/vault/models/view/collection.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { + BitwardenEncryptedIndividualJsonExport, + BitwardenEncryptedOrgJsonExport, + BitwardenJsonExport, + BitwardenUnEncryptedIndividualJsonExport, + BitwardenUnEncryptedOrgJsonExport, +} from "@bitwarden/exporter/vault-export/bitwarden-json-export-types"; import { ImportResult } from "../../models/import-result"; import { BaseImporter } from "../base-importer"; import { Importer } from "../importer"; export class BitwardenJsonImporter extends BaseImporter implements Importer { - private results: any; private result: ImportResult; protected constructor( @@ -24,25 +32,27 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { async parse(data: string): Promise { this.result = new ImportResult(); - this.results = JSON.parse(data); - if (this.results == null || this.results.items == null) { + const results: BitwardenJsonExport = JSON.parse(data); + if (results == null || results.items == null) { this.result.success = false; return this.result; } - if (this.results.encrypted) { - await this.parseEncrypted(); + if (results.encrypted) { + await this.parseEncrypted(results as any); } else { - this.parseDecrypted(); + await this.parseDecrypted(results as any); } return this.result; } - private async parseEncrypted() { - if (this.results.encKeyValidation_DO_NOT_EDIT != null) { + private async parseEncrypted( + results: BitwardenEncryptedIndividualJsonExport | BitwardenEncryptedOrgJsonExport + ) { + if (results.encKeyValidation_DO_NOT_EDIT != null) { const orgKey = await this.cryptoService.getOrgKey(this.organizationId); - const encKeyValidation = new EncString(this.results.encKeyValidation_DO_NOT_EDIT); + const encKeyValidation = new EncString(results.encKeyValidation_DO_NOT_EDIT); const encKeyValidationDecrypt = await this.cryptoService.decryptToUtf8( encKeyValidation, orgKey @@ -54,30 +64,11 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { } } - const groupingsMap = new Map(); + const groupingsMap = this.organization + ? await this.parseCollections(results as BitwardenEncryptedOrgJsonExport) + : await this.parseFolders(results as BitwardenEncryptedIndividualJsonExport); - if (this.organization && this.results.collections != null) { - for (const c of this.results.collections as CollectionWithIdExport[]) { - const collection = CollectionWithIdExport.toDomain(c); - if (collection != null) { - collection.organizationId = this.organizationId; - const view = await collection.decrypt(); - groupingsMap.set(c.id, this.result.collections.length); - this.result.collections.push(view); - } - } - } else if (!this.organization && this.results.folders != null) { - for (const f of this.results.folders as FolderWithIdExport[]) { - const folder = FolderWithIdExport.toDomain(f); - if (folder != null) { - const view = await folder.decrypt(); - groupingsMap.set(f.id, this.result.folders.length); - this.result.folders.push(view); - } - } - } - - for (const c of this.results.items as CipherWithIdExport[]) { + for (const c of results.items) { const cipher = CipherWithIdExport.toDomain(c); // reset ids incase they were set for some reason cipher.id = null; @@ -113,28 +104,14 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { this.result.success = true; } - private parseDecrypted() { - const groupingsMap = new Map(); - if (this.organization && this.results.collections != null) { - this.results.collections.forEach((c: CollectionWithIdExport) => { - const collection = CollectionWithIdExport.toView(c); - if (collection != null) { - collection.organizationId = null; - groupingsMap.set(c.id, this.result.collections.length); - this.result.collections.push(collection); - } - }); - } else if (!this.organization && this.results.folders != null) { - this.results.folders.forEach((f: FolderWithIdExport) => { - const folder = FolderWithIdExport.toView(f); - if (folder != null) { - groupingsMap.set(f.id, this.result.folders.length); - this.result.folders.push(folder); - } - }); - } + private async parseDecrypted( + results: BitwardenUnEncryptedIndividualJsonExport | BitwardenUnEncryptedOrgJsonExport + ) { + const groupingsMap = this.organization + ? await this.parseCollections(results as BitwardenUnEncryptedOrgJsonExport) + : await this.parseFolders(results as BitwardenUnEncryptedIndividualJsonExport); - this.results.items.forEach((c: CipherWithIdExport) => { + results.items.forEach((c) => { const cipher = CipherWithIdExport.toView(c); // reset ids incase they were set for some reason cipher.id = null; @@ -168,4 +145,60 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { this.result.success = true; } + + private async parseFolders( + data: BitwardenUnEncryptedIndividualJsonExport | BitwardenEncryptedIndividualJsonExport + ): Promise> | null { + if (data.folders == null) { + return null; + } + + const groupingsMap = new Map(); + + for (const f of data.folders) { + let folderView: FolderView; + if (data.encrypted) { + const folder = FolderWithIdExport.toDomain(f); + if (folder != null) { + folderView = await folder.decrypt(); + } + } else { + folderView = FolderWithIdExport.toView(f); + } + + if (folderView != null) { + groupingsMap.set(f.id, this.result.folders.length); + this.result.folders.push(folderView); + } + } + return groupingsMap; + } + + private async parseCollections( + data: BitwardenUnEncryptedOrgJsonExport | BitwardenEncryptedOrgJsonExport + ): Promise> | null { + if (data.collections == null) { + return null; + } + + const groupingsMap = new Map(); + + for (const c of data.collections) { + let collectionView: CollectionView; + if (data.encrypted) { + const collection = CollectionWithIdExport.toDomain(c); + collection.organizationId = this.organizationId; + collectionView = await collection.decrypt(); + } else { + collectionView = CollectionWithIdExport.toView(c); + collectionView.organizationId = null; + } + + if (collectionView != null) { + groupingsMap.set(c.id, this.result.collections.length); + this.result.collections.push(collectionView); + } + } + return groupingsMap; + } } diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts index 80872b6804..a8c2a711a0 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts @@ -4,7 +4,7 @@ import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.se import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { BitwardenPasswordProtectedFileFormat } from "@bitwarden/exporter/vault-export/bitwarden-password-protected-types"; +import { BitwardenPasswordProtectedFileFormat } from "@bitwarden/exporter/vault-export/bitwarden-json-export-types"; import { ImportResult } from "../../models/import-result"; import { Importer } from "../importer";