1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-12-05 09:14:28 +01:00
This commit is contained in:
Daniel James Smith 2025-12-04 18:38:36 -06:00 committed by GitHub
commit 10caf959c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 314 additions and 185 deletions

View File

@ -0,0 +1,188 @@
// 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,
isOrgEncrypted,
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<ImportResult> {
const results: BitwardenJsonExport = JSON.parse(data);
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<ImportResult> {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (data.encKeyValidation_DO_NOT_EDIT != null) {
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) {
const result = new ImportResult();
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 {
const result = new ImportResult();
result.success = false;
result.errorMessage = this.i18nService.t("importEncKeyError");
return result;
}
}
const result = new ImportResult();
let groupingsMap: Map<string, number> | null = null;
if (isOrgEncrypted(data)) {
groupingsMap = await this.parseEncryptedCollections(account.id, data, result);
} else {
groupingsMap = await this.parseEncryptedFolders(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(
data: BitwardenEncryptedIndividualJsonExport,
importResult: ImportResult,
): Promise<Map<string, number>> {
const groupingsMap = new Map<string, number>();
if (data.folders == null) {
return groupingsMap;
}
for (const f of data.folders) {
let folderView: FolderView;
const folder = FolderWithIdExport.toDomain(f);
if (folder != null) {
folderView = await folder.decrypt();
}
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<Map<string, number>> {
const groupingsMap = new Map<string, number>();
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;
}
}

View File

@ -1,31 +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 { getUserId } from "@bitwarden/common/auth/services/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";
@ -33,104 +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<ImportResult> {
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);
} else {
await this.parseDecrypted(results as any);
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,
) {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
private async parseDecrypted(results: BitwardenUnEncryptedJsonExport): Promise<ImportResult> {
const importResult = new ImportResult();
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;
}
}
const groupingsMap = this.organization
? await this.parseCollections(userId, results as BitwardenEncryptedOrgJsonExport)
: await this.parseFolders(results as BitwardenEncryptedIndividualJsonExport);
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,
) {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const groupingsMap = this.organization
? await this.parseCollections(userId, results as BitwardenUnEncryptedOrgJsonExport)
: await this.parseFolders(results as BitwardenUnEncryptedIndividualJsonExport);
const groupingsMap = isOrgUnEncrypted(results)
? await this.parseCollections(results, importResult)
: await this.parseFolders(results, importResult);
results.items.forEach((c) => {
const cipher = CipherWithIdExport.toView(c);
@ -145,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),
]);
}
@ -161,76 +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,
): Promise<Map<string, number>> | null {
data: BitwardenUnEncryptedIndividualJsonExport,
importResult: ImportResult,
): Promise<Map<string, number>> {
const groupingsMap = new Map<string, number>();
if (data.folders == null) {
return null;
return groupingsMap;
}
const groupingsMap = new Map<string, number>();
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);
}
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(
userId: UserId,
data: BitwardenUnEncryptedOrgJsonExport | BitwardenEncryptedOrgJsonExport,
): Promise<Map<string, number>> | null {
data: BitwardenUnEncryptedOrgJsonExport,
importResult: ImportResult,
): Promise<Map<string, number>> {
const groupingsMap = new Map<string, number>();
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<string, number>();
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;

View File

@ -15,6 +15,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";
@ -90,7 +91,7 @@ describe("BitwardenPasswordProtectedImporter", () => {
describe("Account encrypted", () => {
beforeAll(() => {
jest.spyOn(BitwardenJsonImporter.prototype, "parse");
jest.spyOn(BitwardenEncryptedJsonImporter.prototype, "parse");
});
beforeEach(() => {
@ -111,9 +112,11 @@ describe("BitwardenPasswordProtectedImporter", () => {
);
});
it("Should call BitwardenJsonImporter", async () => {
it("Should call BitwardenEncryptedJsonImporter", async () => {
expect((await importer.parse(emptyAccountEncrypted)).success).toEqual(true);
expect(BitwardenJsonImporter.prototype.parse).toHaveBeenCalledWith(emptyAccountEncrypted);
expect(BitwardenEncryptedJsonImporter.prototype.parse).toHaveBeenCalledWith(
emptyAccountEncrypted,
);
});
});

View File

@ -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<ImportResult> {
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);
}

View File

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