diff --git a/libs/importer/src/importers/bitwarden/bitwarden-encrypted-json-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-encrypted-json-importer.ts new file mode 100644 index 00000000000..4771f47b4c9 --- /dev/null +++ b/libs/importer/src/importers/bitwarden/bitwarden-encrypted-json-importer.ts @@ -0,0 +1,201 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { filter, firstValueFrom } from "rxjs"; + +// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. +// eslint-disable-next-line no-restricted-imports +import { Collection } from "@bitwarden/admin-console/common"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; +import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { + CipherWithIdExport, + CollectionWithIdExport, + FolderWithIdExport, +} from "@bitwarden/common/models/export"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { OrgKey, UserKey } from "@bitwarden/common/types/key"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; +import { + BitwardenEncryptedIndividualJsonExport, + BitwardenEncryptedJsonExport, + BitwardenEncryptedOrgJsonExport, + BitwardenJsonExport, + BitwardenPasswordProtectedFileFormat, + isOrgEncrypted, + isPasswordProtected, + isUnencrypted, +} from "@bitwarden/vault-export-core"; + +import { ImportResult } from "../../models/import-result"; +import { Importer } from "../importer"; + +import { BitwardenJsonImporter } from "./bitwarden-json-importer"; + +export class BitwardenEncryptedJsonImporter extends BitwardenJsonImporter implements Importer { + constructor( + protected keyService: KeyService, + protected encryptService: EncryptService, + protected i18nService: I18nService, + private cipherService: CipherService, + private accountService: AccountService, + ) { + super(); + } + + async parse(data: string): Promise { + const results: BitwardenPasswordProtectedFileFormat | BitwardenJsonExport = JSON.parse(data); + + if (isPasswordProtected(results)) { + throw new Error( + "Data is password-protected. Use BitwardenPasswordProtectedImporter instead.", + ); + } + + if (results == null || results.items == null) { + const result = new ImportResult(); + result.success = false; + return result; + } + + if (isUnencrypted(results)) { + return super.parse(data); + } + + return await this.parseEncrypted(results); + } + + private async parseEncrypted(data: BitwardenEncryptedJsonExport): Promise { + const result = new ImportResult(); + const account = await firstValueFrom(this.accountService.activeAccount$); + + if (this.isNullOrWhitespace(data.encKeyValidation_DO_NOT_EDIT)) { + result.success = false; + result.errorMessage = this.i18nService.t("importEncKeyError"); + return result; + } + + const orgKeys = await firstValueFrom(this.keyService.orgKeys$(account.id)); + let keyForDecryption: OrgKey | UserKey | null | undefined = orgKeys?.[this.organizationId]; + if (!keyForDecryption) { + keyForDecryption = await firstValueFrom(this.keyService.userKey$(account.id)); + } + + if (!keyForDecryption) { + result.success = false; + result.errorMessage = this.i18nService.t("importEncKeyError"); + return result; + } + const encKeyValidation = new EncString(data.encKeyValidation_DO_NOT_EDIT); + try { + await this.encryptService.decryptString(encKeyValidation, keyForDecryption); + } catch { + result.success = false; + result.errorMessage = this.i18nService.t("importEncKeyError"); + return result; + } + + let groupingsMap: Map | null = null; + if (isOrgEncrypted(data)) { + groupingsMap = await this.parseEncryptedCollections(account.id, data, result); + } else { + groupingsMap = await this.parseEncryptedFolders(account.id, data, result); + } + + for (const c of data.items) { + const cipher = CipherWithIdExport.toDomain(c); + // reset ids in case they were set for some reason + cipher.id = null; + cipher.organizationId = this.organizationId; + cipher.collectionIds = null; + + // make sure password history is limited + if (cipher.passwordHistory != null && cipher.passwordHistory.length > 5) { + cipher.passwordHistory = cipher.passwordHistory.slice(0, 5); + } + + if (!this.organization && c.folderId != null && groupingsMap.has(c.folderId)) { + result.folderRelationships.push([result.ciphers.length, groupingsMap.get(c.folderId)]); + } else if (this.organization && c.collectionIds != null) { + c.collectionIds.forEach((cId) => { + if (groupingsMap.has(cId)) { + result.collectionRelationships.push([result.ciphers.length, groupingsMap.get(cId)]); + } + }); + } + + const view = await this.cipherService.decrypt(cipher, account.id); + this.cleanupCipher(view); + result.ciphers.push(view); + } + + result.success = true; + return result; + } + + private async parseEncryptedFolders( + userId: UserId, + data: BitwardenEncryptedIndividualJsonExport, + importResult: ImportResult, + ): Promise> { + const groupingsMap = new Map(); + + if (data.folders == null) { + return groupingsMap; + } + + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + + for (const f of data.folders) { + let folderView: FolderView; + const folder = FolderWithIdExport.toDomain(f); + if (folder != null) { + folderView = await folder.decrypt(userKey); + } + + if (folderView != null) { + groupingsMap.set(f.id, importResult.folders.length); + importResult.folders.push(folderView); + } + } + return groupingsMap; + } + + private async parseEncryptedCollections( + userId: UserId, + data: BitwardenEncryptedOrgJsonExport, + importResult: ImportResult, + ): Promise> { + const groupingsMap = new Map(); + if (data.collections == null) { + return groupingsMap; + } + + const orgKeys = await firstValueFrom( + this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => orgKeys != null)), + ); + + for (const c of data.collections) { + const collection = CollectionWithIdExport.toDomain( + c, + new Collection({ + id: c.id, + name: new EncString(c.name), + organizationId: this.organizationId, + }), + ); + + const orgKey = orgKeys[c.organizationId]; + const collectionView = await collection.decrypt(orgKey, this.encryptService); + + if (collectionView != null) { + groupingsMap.set(c.id, importResult.collections.length); + importResult.collections.push(collectionView); + } + } + return groupingsMap; + } +} diff --git a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts index 1f5be7f18ab..ddeba885f88 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts @@ -1,30 +1,17 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { filter, firstValueFrom } from "rxjs"; - -// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. -// eslint-disable-next-line no-restricted-imports -import { Collection, CollectionView } from "@bitwarden/admin-console/common"; -import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; -import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; -import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { CipherWithIdExport, CollectionWithIdExport, FolderWithIdExport, } from "@bitwarden/common/models/export"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; -import { UserId } from "@bitwarden/common/types/guid"; -import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; -import { KeyService } from "@bitwarden/key-management"; import { - BitwardenEncryptedIndividualJsonExport, - BitwardenEncryptedOrgJsonExport, BitwardenJsonExport, BitwardenUnEncryptedIndividualJsonExport, + BitwardenUnEncryptedJsonExport, BitwardenUnEncryptedOrgJsonExport, + isOrgUnEncrypted, + isUnencrypted, } from "@bitwarden/vault-export-core"; import { ImportResult } from "../../models/import-result"; @@ -32,103 +19,30 @@ import { BaseImporter } from "../base-importer"; import { Importer } from "../importer"; export class BitwardenJsonImporter extends BaseImporter implements Importer { - private result: ImportResult; - - protected constructor( - protected keyService: KeyService, - protected encryptService: EncryptService, - protected i18nService: I18nService, - protected cipherService: CipherService, - protected accountService: AccountService, - ) { + protected constructor() { super(); } async parse(data: string): Promise { - const account = await firstValueFrom(this.accountService.activeAccount$); - this.result = new ImportResult(); const results: BitwardenJsonExport = JSON.parse(data); if (results == null || results.items == null) { - this.result.success = false; - return this.result; + const result = new ImportResult(); + result.success = false; + return result; } - if (results.encrypted) { - await this.parseEncrypted(results as any, account.id); - } else { - await this.parseDecrypted(results as any, account.id); + if (!isUnencrypted(results)) { + throw new Error("Data is encrypted. Use BitwardenEncryptedJsonImporter instead."); } - - return this.result; + return await this.parseDecrypted(results); } - private async parseEncrypted( - results: BitwardenEncryptedIndividualJsonExport | BitwardenEncryptedOrgJsonExport, - userId: UserId, - ) { - if (results.encKeyValidation_DO_NOT_EDIT != null) { - const orgKeys = await firstValueFrom(this.keyService.orgKeys$(userId)); - let keyForDecryption: SymmetricCryptoKey = orgKeys?.[this.organizationId]; - if (keyForDecryption == null) { - keyForDecryption = await firstValueFrom(this.keyService.userKey$(userId)); - } - const encKeyValidation = new EncString(results.encKeyValidation_DO_NOT_EDIT); - try { - await this.encryptService.decryptString(encKeyValidation, keyForDecryption); - } catch { - this.result.success = false; - this.result.errorMessage = this.i18nService.t("importEncKeyError"); - return; - } - } + private async parseDecrypted(results: BitwardenUnEncryptedJsonExport): Promise { + const importResult = new ImportResult(); - const groupingsMap = this.organization - ? await this.parseCollections(results as BitwardenEncryptedOrgJsonExport, userId) - : await this.parseFolders(results as BitwardenEncryptedIndividualJsonExport, userId); - - for (const c of results.items) { - const cipher = CipherWithIdExport.toDomain(c); - // reset ids in case they were set for some reason - cipher.id = null; - cipher.organizationId = this.organizationId; - cipher.collectionIds = null; - - // make sure password history is limited - if (cipher.passwordHistory != null && cipher.passwordHistory.length > 5) { - cipher.passwordHistory = cipher.passwordHistory.slice(0, 5); - } - - if (!this.organization && c.folderId != null && groupingsMap.has(c.folderId)) { - this.result.folderRelationships.push([ - this.result.ciphers.length, - groupingsMap.get(c.folderId), - ]); - } else if (this.organization && c.collectionIds != null) { - c.collectionIds.forEach((cId) => { - if (groupingsMap.has(cId)) { - this.result.collectionRelationships.push([ - this.result.ciphers.length, - groupingsMap.get(cId), - ]); - } - }); - } - - const view = await this.cipherService.decrypt(cipher, userId); - this.cleanupCipher(view); - this.result.ciphers.push(view); - } - - this.result.success = true; - } - - private async parseDecrypted( - results: BitwardenUnEncryptedIndividualJsonExport | BitwardenUnEncryptedOrgJsonExport, - userId: UserId, - ) { - const groupingsMap = this.organization - ? await this.parseCollections(results as BitwardenUnEncryptedOrgJsonExport, userId) - : await this.parseFolders(results as BitwardenUnEncryptedIndividualJsonExport, userId); + const groupingsMap = isOrgUnEncrypted(results) + ? await this.parseCollections(results, importResult) + : await this.parseFolders(results, importResult); results.items.forEach((c) => { const cipher = CipherWithIdExport.toView(c); @@ -143,15 +57,15 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { } if (!this.organization && c.folderId != null && groupingsMap.has(c.folderId)) { - this.result.folderRelationships.push([ - this.result.ciphers.length, + importResult.folderRelationships.push([ + importResult.ciphers.length, groupingsMap.get(c.folderId), ]); } else if (this.organization && c.collectionIds != null) { c.collectionIds.forEach((cId) => { if (groupingsMap.has(cId)) { - this.result.collectionRelationships.push([ - this.result.ciphers.length, + importResult.collectionRelationships.push([ + importResult.ciphers.length, groupingsMap.get(cId), ]); } @@ -159,79 +73,48 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { } this.cleanupCipher(cipher); - this.result.ciphers.push(cipher); + importResult.ciphers.push(cipher); }); - this.result.success = true; + importResult.success = true; + return importResult; } private async parseFolders( - data: BitwardenUnEncryptedIndividualJsonExport | BitwardenEncryptedIndividualJsonExport, - userId: UserId, - ): Promise> | null { + data: BitwardenUnEncryptedIndividualJsonExport, + importResult: ImportResult, + ): Promise> { + const groupingsMap = new Map(); if (data.folders == null) { - return null; + return groupingsMap; } - const userKey = await firstValueFrom(this.keyService.userKey$(userId)); - - 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(userKey); - } - } else { - folderView = FolderWithIdExport.toView(f); - } - + const folderView = FolderWithIdExport.toView(f); if (folderView != null) { - groupingsMap.set(f.id, this.result.folders.length); - this.result.folders.push(folderView); + groupingsMap.set(f.id, importResult.folders.length); + importResult.folders.push(folderView); } } return groupingsMap; } private async parseCollections( - data: BitwardenUnEncryptedOrgJsonExport | BitwardenEncryptedOrgJsonExport, - userId: UserId, - ): Promise> | null { + data: BitwardenUnEncryptedOrgJsonExport, + importResult: ImportResult, + ): Promise> { + const groupingsMap = new Map(); if (data.collections == null) { - return null; + return groupingsMap; } - const orgKeys = await firstValueFrom( - this.keyService.orgKeys$(userId).pipe(filter((orgKeys) => orgKeys != null)), - ); - - const groupingsMap = new Map(); - for (const c of data.collections) { - let collectionView: CollectionView; - if (data.encrypted) { - const collection = CollectionWithIdExport.toDomain( - c, - new Collection({ - id: c.id, - name: new EncString(c.name), - organizationId: this.organizationId, - }), - ); - - const orgKey = orgKeys[c.organizationId]; - collectionView = await collection.decrypt(orgKey, this.encryptService); - } else { - collectionView = CollectionWithIdExport.toView(c); - collectionView.organizationId = null; - } + const collectionView = CollectionWithIdExport.toView(c); + collectionView.organizationId = null; if (collectionView != null) { - groupingsMap.set(c.id, this.result.collections.length); - this.result.collections.push(collectionView); + groupingsMap.set(c.id, importResult.collections.length); + importResult.collections.push(collectionView); } } return groupingsMap; diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts index fdf92cac751..ff6e3692640 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts @@ -16,6 +16,7 @@ import { UserId } from "@bitwarden/user-core"; import { emptyAccountEncrypted } from "../spec-data/bitwarden-json/account-encrypted.json"; import { emptyUnencryptedExport } from "../spec-data/bitwarden-json/unencrypted.json"; +import { BitwardenEncryptedJsonImporter } from "./bitwarden-encrypted-json-importer"; import { BitwardenJsonImporter } from "./bitwarden-json-importer"; import { BitwardenPasswordProtectedImporter } from "./bitwarden-password-protected-importer"; @@ -92,7 +93,7 @@ describe("BitwardenPasswordProtectedImporter", () => { describe("Account encrypted", () => { beforeAll(() => { - jest.spyOn(BitwardenJsonImporter.prototype, "parse"); + jest.spyOn(BitwardenEncryptedJsonImporter.prototype, "parse"); }); beforeEach(() => { @@ -114,9 +115,11 @@ describe("BitwardenPasswordProtectedImporter", () => { ); }); - it("Should call BitwardenJsonImporter", async () => { - expect((await importer.parse(emptyAccountEncrypted)).success).toEqual(true); - expect(BitwardenJsonImporter.prototype.parse).toHaveBeenCalledWith(emptyAccountEncrypted); + it("Should call BitwardenEncryptedJsonImporter", async () => { + expect((await importer.parse(emptyAccountEncrypted)).success).toEqual(false); + expect(BitwardenEncryptedJsonImporter.prototype.parse).toHaveBeenCalledWith( + emptyAccountEncrypted, + ); }); }); 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 b685ddf0fb5..cc38c420d9b 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.ts @@ -14,14 +14,21 @@ import { KeyService, KdfType, } from "@bitwarden/key-management"; -import { BitwardenPasswordProtectedFileFormat } from "@bitwarden/vault-export-core"; +import { + BitwardenJsonExport, + BitwardenPasswordProtectedFileFormat, + isPasswordProtected, +} from "@bitwarden/vault-export-core"; import { ImportResult } from "../../models/import-result"; import { Importer } from "../importer"; -import { BitwardenJsonImporter } from "./bitwarden-json-importer"; +import { BitwardenEncryptedJsonImporter } from "./bitwarden-encrypted-json-importer"; -export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter implements Importer { +export class BitwardenPasswordProtectedImporter + extends BitwardenEncryptedJsonImporter + implements Importer +{ private key: SymmetricCryptoKey; constructor( @@ -38,20 +45,14 @@ export class BitwardenPasswordProtectedImporter extends BitwardenJsonImporter im async parse(data: string): Promise { const result = new ImportResult(); - const parsedData: BitwardenPasswordProtectedFileFormat = JSON.parse(data); + const parsedData: BitwardenPasswordProtectedFileFormat | BitwardenJsonExport = JSON.parse(data); if (!parsedData) { result.success = false; return result; } - // File is unencrypted - if (!parsedData?.encrypted) { - return await super.parse(data); - } - - // File is account-encrypted - if (!parsedData?.passwordProtected) { + if (!isPasswordProtected(parsedData)) { return await super.parse(data); } diff --git a/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-json-export-types.ts b/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-json-export-types.ts index ab2bcbb9f1f..fd33bf96923 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-json-export-types.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/types/bitwarden-json-export-types.ts @@ -5,42 +5,48 @@ import { } from "@bitwarden/common/models/export"; // Base -export type BitwardenJsonExport = { - encrypted: boolean; - items: CipherWithIdExport[]; -}; +export type BitwardenJsonExport = BitwardenUnEncryptedJsonExport | BitwardenEncryptedJsonExport; // Decrypted -export type BitwardenUnEncryptedJsonExport = BitwardenJsonExport & { - encrypted: false; -}; +export type BitwardenUnEncryptedJsonExport = + | BitwardenUnEncryptedIndividualJsonExport + | BitwardenUnEncryptedOrgJsonExport; -export type BitwardenUnEncryptedIndividualJsonExport = BitwardenUnEncryptedJsonExport & { +export type BitwardenUnEncryptedIndividualJsonExport = { + encrypted: false; + items: CipherWithIdExport[]; folders: FolderWithIdExport[]; }; -export type BitwardenUnEncryptedOrgJsonExport = BitwardenUnEncryptedJsonExport & { +export type BitwardenUnEncryptedOrgJsonExport = { + encrypted: false; + items: CipherWithIdExport[]; collections: CollectionWithIdExport[]; }; // Account-encrypted -export type BitwardenEncryptedJsonExport = BitwardenJsonExport & { +export type BitwardenEncryptedJsonExport = + | BitwardenEncryptedIndividualJsonExport + | BitwardenEncryptedOrgJsonExport; + +export type BitwardenEncryptedIndividualJsonExport = { encrypted: true; encKeyValidation_DO_NOT_EDIT: string; -}; - -export type BitwardenEncryptedIndividualJsonExport = BitwardenEncryptedJsonExport & { + items: CipherWithIdExport[]; folders: FolderWithIdExport[]; }; -export type BitwardenEncryptedOrgJsonExport = BitwardenEncryptedJsonExport & { +export type BitwardenEncryptedOrgJsonExport = { + encrypted: true; + encKeyValidation_DO_NOT_EDIT: string; + items: CipherWithIdExport[]; collections: CollectionWithIdExport[]; }; // Password-protected export type BitwardenPasswordProtectedFileFormat = { - encrypted: boolean; - passwordProtected: boolean; + encrypted: true; + passwordProtected: true; salt: string; kdfIterations: number; kdfMemory?: number; @@ -49,3 +55,50 @@ export type BitwardenPasswordProtectedFileFormat = { encKeyValidation_DO_NOT_EDIT: string; data: string; }; + +// Unencrypted type guards +export function isUnencrypted( + data: BitwardenJsonExport | null | undefined, +): data is BitwardenUnEncryptedJsonExport { + return data != null && (data as { encrypted?: unknown }).encrypted !== true; +} + +export function isIndividualUnEncrypted( + data: BitwardenJsonExport | null | undefined, +): data is BitwardenUnEncryptedIndividualJsonExport { + return isUnencrypted(data) && (data as { folders?: unknown }).folders != null; +} + +export function isOrgUnEncrypted( + data: BitwardenJsonExport | null | undefined, +): data is BitwardenUnEncryptedOrgJsonExport { + return isUnencrypted(data) && (data as { collections?: unknown }).collections != null; +} + +// Encrypted type guards +export function isEncrypted( + data: BitwardenJsonExport | null | undefined, +): data is BitwardenEncryptedJsonExport { + return data != null && (data as { encrypted?: unknown }).encrypted === true; +} +export function isPasswordProtected( + data: BitwardenPasswordProtectedFileFormat | BitwardenJsonExport | null | undefined, +): data is BitwardenPasswordProtectedFileFormat { + return ( + data != null && + (data as { encrypted?: unknown }).encrypted === true && + (data as { passwordProtected?: unknown }).passwordProtected === true + ); +} + +export function isIndividualEncrypted( + data: BitwardenJsonExport | null | undefined, +): data is BitwardenEncryptedIndividualJsonExport { + return isEncrypted(data) && (data as { folders?: unknown }).folders != null; +} + +export function isOrgEncrypted( + data: BitwardenJsonExport | null | undefined, +): data is BitwardenEncryptedOrgJsonExport { + return isEncrypted(data) && (data as { collections?: unknown }).collections != null; +}