diff --git a/libs/common/spec/services/stateMigration.service.spec.ts b/libs/common/spec/services/stateMigration.service.spec.ts index e306e64e29..b0188abacc 100644 --- a/libs/common/spec/services/stateMigration.service.spec.ts +++ b/libs/common/spec/services/stateMigration.service.spec.ts @@ -116,8 +116,8 @@ describe("State Migration Service", () => { key: "orgThreeEncKey", }, }, - }, - }, + } as any, + } as any, }); const migratedAccount = await (stateMigrationService as any).migrateAccountFrom4To5( diff --git a/libs/common/src/models/domain/account-profile.spec.ts b/libs/common/src/models/domain/account-profile.spec.ts new file mode 100644 index 0000000000..7c6deda34e --- /dev/null +++ b/libs/common/src/models/domain/account-profile.spec.ts @@ -0,0 +1,9 @@ +import { AccountProfile } from "./account"; + +describe("AccountProfile", () => { + describe("fromJSON", () => { + it("should deserialize to an instance of itself", () => { + expect(AccountProfile.fromJSON({})).toBeInstanceOf(AccountProfile); + }); + }); +}); diff --git a/libs/common/src/models/domain/account-settings.spec.ts b/libs/common/src/models/domain/account-settings.spec.ts new file mode 100644 index 0000000000..e1a4913630 --- /dev/null +++ b/libs/common/src/models/domain/account-settings.spec.ts @@ -0,0 +1,25 @@ +import { AccountSettings, EncryptionPair } from "./account"; +import { EncString } from "./encString"; + +describe("AccountSettings", () => { + describe("fromJSON", () => { + it("should deserialize to an instance of itself", () => { + expect(AccountSettings.fromJSON(JSON.parse("{}"))).toBeInstanceOf(AccountSettings); + }); + + it("should deserialize pinProtected", () => { + const accountSettings = new AccountSettings(); + accountSettings.pinProtected = EncryptionPair.fromJSON({ + encrypted: "encrypted", + decrypted: "3.data", + decryptedSerialized: null, + }); + const jsonObj = JSON.parse(JSON.stringify(accountSettings)); + const actual = AccountSettings.fromJSON(jsonObj); + + expect(actual.pinProtected).toBeInstanceOf(EncryptionPair); + expect(actual.pinProtected.encrypted).toEqual("encrypted"); + expect(actual.pinProtected.decrypted.encryptedString).toEqual("3.data"); + }); + }); +}); diff --git a/libs/common/src/models/domain/account-tokens.spec.ts b/libs/common/src/models/domain/account-tokens.spec.ts new file mode 100644 index 0000000000..733b3908e9 --- /dev/null +++ b/libs/common/src/models/domain/account-tokens.spec.ts @@ -0,0 +1,9 @@ +import { AccountTokens } from "./account"; + +describe("AccountTokens", () => { + describe("fromJSON", () => { + it("should deserialize to an instance of itself", () => { + expect(AccountTokens.fromJSON({})).toBeInstanceOf(AccountTokens); + }); + }); +}); diff --git a/libs/common/src/models/domain/account.spec.ts b/libs/common/src/models/domain/account.spec.ts new file mode 100644 index 0000000000..0c76c16cc2 --- /dev/null +++ b/libs/common/src/models/domain/account.spec.ts @@ -0,0 +1,23 @@ +import { Account, AccountKeys, AccountProfile, AccountSettings, AccountTokens } from "./account"; + +describe("Account", () => { + describe("fromJSON", () => { + it("should deserialize to an instance of itself", () => { + expect(Account.fromJSON({})).toBeInstanceOf(Account); + }); + + it("should call all the sub-fromJSONs", () => { + const keysSpy = jest.spyOn(AccountKeys, "fromJSON"); + const profileSpy = jest.spyOn(AccountProfile, "fromJSON"); + const settingsSpy = jest.spyOn(AccountSettings, "fromJSON"); + const tokensSpy = jest.spyOn(AccountTokens, "fromJSON"); + + Account.fromJSON({}); + + expect(keysSpy).toHaveBeenCalled(); + expect(profileSpy).toHaveBeenCalled(); + expect(settingsSpy).toHaveBeenCalled(); + expect(tokensSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/common/src/models/domain/account.ts b/libs/common/src/models/domain/account.ts index dfeacce120..3016111d04 100644 --- a/libs/common/src/models/domain/account.ts +++ b/libs/common/src/models/domain/account.ts @@ -1,3 +1,7 @@ +import { Jsonify } from "type-fest"; + +import { Utils } from "@bitwarden/common/misc/utils"; + import { AuthenticationStatus } from "../../enums/authenticationStatus"; import { KdfType } from "../../enums/kdfType"; import { UriMatchType } from "../../enums/uriMatchType"; @@ -23,7 +27,39 @@ import { SymmetricCryptoKey } from "./symmetricCryptoKey"; export class EncryptionPair { encrypted?: TEncrypted; decrypted?: TDecrypted; - decryptedSerialized?: string; + private decryptedSerialized?: string; + + toJSON() { + return { + encrypted: this.encrypted, + decrypted: this.decrypted, + decryptedSerialized: + this.decrypted instanceof ArrayBuffer ? Utils.fromBufferToByteString(this.decrypted) : null, + }; + } + + static fromJSON( + obj: Jsonify, Jsonify>>, + decryptedFromJson?: (obj: Jsonify) => TDecrypted, + encryptedFromJson?: (obj: Jsonify) => TEncrypted + ) { + const pair = new EncryptionPair(); + if (obj?.encrypted) { + pair.encrypted = encryptedFromJson + ? encryptedFromJson(obj.encrypted as any) + : (obj.encrypted as TEncrypted); + } + if (obj?.decryptedSerialized) { + pair.decryptedSerialized = obj.decryptedSerialized; + // We only populate the decryptedSerialized if the decrypted is an arraybuffer. + pair.decrypted = Utils.fromByteStringToArray(obj.decryptedSerialized)?.buffer as any; + } else if (obj?.decrypted) { + pair.decrypted = decryptedFromJson + ? decryptedFromJson(obj.decrypted as any) + : (obj.decrypted as TDecrypted); + } + return pair; + } } export class DataEncryptionPair { @@ -83,8 +119,50 @@ export class AccountKeys { >(); privateKey?: EncryptionPair = new EncryptionPair(); publicKey?: ArrayBuffer; - publicKeySerialized?: string; + private publicKeySerialized?: string; apiKeyClientSecret?: string; + + toJSON() { + this.publicKeySerialized = Utils.fromBufferToByteString(this.publicKey); + return this; + } + + static fromJSON(obj: any): AccountKeys { + return Object.assign( + new AccountKeys(), + { cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey) }, + { + cryptoSymmetricKey: EncryptionPair.fromJSON( + obj?.cryptoSymmetricKey, + SymmetricCryptoKey.fromJSON + ), + }, + { + organizationKeys: EncryptionPair.fromJSON(obj?.organizationKeys, (obj: any) => { + const map = new Map(); + for (const orgId in obj) { + map.set(orgId, SymmetricCryptoKey.fromJSON(obj[orgId])); + } + return map; + }), + }, + { + providerKeys: EncryptionPair.fromJSON(obj?.providerKeys, (obj: any) => { + const map = new Map(); + for (const providerId in obj) { + map.set(providerId, SymmetricCryptoKey.fromJSON(obj[providerId])); + } + return map; + }), + }, + { + privateKey: EncryptionPair.fromJSON(obj?.privateKey), + }, + { + publicKey: Utils.fromByteStringToArray(obj?.publicKeySerialized)?.buffer, + } + ); + } } export class AccountProfile { @@ -105,6 +183,10 @@ export class AccountProfile { keyHash?: string; kdfIterations?: number; kdfType?: KdfType; + + static fromJSON(obj: Jsonify): AccountProfile { + return Object.assign(new AccountProfile(), obj); + } } export class AccountSettings { @@ -140,6 +222,15 @@ export class AccountSettings { settings?: AccountSettingsSettings; // TODO: Merge whatever is going on here into the AccountSettings model properly vaultTimeout?: number; vaultTimeoutAction?: string = "lock"; + + static fromJSON(obj: Jsonify): AccountSettings { + return Object.assign(new AccountSettings(), obj, { + pinProtected: EncryptionPair.fromJSON( + obj?.pinProtected, + EncString.fromJSON + ), + }); + } } export type AccountSettingsSettings = { @@ -151,6 +242,10 @@ export class AccountTokens { decodedToken?: any; refreshToken?: string; securityStamp?: string; + + static fromJSON(obj: Jsonify): AccountTokens { + return Object.assign(new AccountTokens(), obj); + } } export class Account { @@ -184,4 +279,13 @@ export class Account { }, }); } + + static fromJSON(json: any): Account { + return Object.assign(new Account({}), json, { + keys: AccountKeys.fromJSON(json?.keys as any), + profile: AccountProfile.fromJSON(json?.profile), + settings: AccountSettings.fromJSON(json?.settings as any), + tokens: AccountTokens.fromJSON(json?.tokens as any), + }); + } } diff --git a/libs/common/src/models/domain/acount-keys.spec.ts b/libs/common/src/models/domain/acount-keys.spec.ts new file mode 100644 index 0000000000..e05065e344 --- /dev/null +++ b/libs/common/src/models/domain/acount-keys.spec.ts @@ -0,0 +1,68 @@ +import { Utils } from "@bitwarden/common/misc/utils"; + +import { makeStaticByteArray } from "../../../spec/utils"; + +import { AccountKeys, EncryptionPair } from "./account"; +import { SymmetricCryptoKey } from "./symmetricCryptoKey"; + +describe("AccountKeys", () => { + describe("toJSON", () => { + it("should serialize itself", () => { + const keys = new AccountKeys(); + const buffer = makeStaticByteArray(64).buffer; + const symmetricKey = new SymmetricCryptoKey(buffer); + keys.cryptoMasterKey = symmetricKey; + keys.publicKey = buffer; + keys.cryptoSymmetricKey = new EncryptionPair(); + keys.cryptoSymmetricKey.decrypted = symmetricKey; + + const symmetricKeySpy = jest.spyOn(symmetricKey, "toJSON"); + const actual = JSON.stringify(keys.toJSON()); + expect(symmetricKeySpy).toHaveBeenCalled(); + expect(actual).toContain(`"cryptoMasterKey":${JSON.stringify(symmetricKey.toJSON())}`); + expect(actual).toContain( + `"publicKeySerialized":${JSON.stringify(Utils.fromBufferToByteString(buffer))}` + ); + }); + + it("should serialize public key as a string", () => { + const keys = new AccountKeys(); + keys.publicKey = Utils.fromByteStringToArray("hello").buffer; + const json = JSON.stringify(keys); + expect(json).toContain('"publicKeySerialized":"hello"'); + }); + }); + + describe("fromJSON", () => { + it("should deserialize public key to a buffer", () => { + const keys = AccountKeys.fromJSON({ + publicKeySerialized: "hello", + }); + expect(keys.publicKey).toEqual(Utils.fromByteStringToArray("hello").buffer); + }); + + it("should deserialize cryptoMasterKey", () => { + const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON"); + AccountKeys.fromJSON({}); + expect(spy).toHaveBeenCalled(); + }); + + it("should deserialize organizationKeys", () => { + const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON"); + AccountKeys.fromJSON({ organizationKeys: [{ orgId: "keyJSON" }] }); + expect(spy).toHaveBeenCalled(); + }); + + it("should deserialize providerKeys", () => { + const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON"); + AccountKeys.fromJSON({ providerKeys: [{ providerId: "keyJSON" }] }); + expect(spy).toHaveBeenCalled(); + }); + + it("should deserialize privateKey", () => { + const spy = jest.spyOn(EncryptionPair, "fromJSON"); + AccountKeys.fromJSON({ privateKey: { encrypted: "encrypted", decrypted: "decrypted" } }); + expect(spy).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/common/src/models/domain/encryption-pair.spec.ts b/libs/common/src/models/domain/encryption-pair.spec.ts new file mode 100644 index 0000000000..706a3406ba --- /dev/null +++ b/libs/common/src/models/domain/encryption-pair.spec.ts @@ -0,0 +1,44 @@ +import { Utils } from "@bitwarden/common/misc/utils"; + +import { EncryptionPair } from "./account"; + +describe("EncryptionPair", () => { + describe("toJSON", () => { + it("should populate decryptedSerialized for buffer arrays", () => { + const pair = new EncryptionPair(); + pair.decrypted = Utils.fromByteStringToArray("hello").buffer; + const json = pair.toJSON(); + expect(json.decryptedSerialized).toEqual("hello"); + }); + + it("should serialize encrypted and decrypted", () => { + const pair = new EncryptionPair(); + pair.encrypted = "hello"; + pair.decrypted = "world"; + const json = pair.toJSON(); + expect(json.encrypted).toEqual("hello"); + expect(json.decrypted).toEqual("world"); + }); + }); + + describe("fromJSON", () => { + it("should deserialize encrypted and decrypted", () => { + const pair = EncryptionPair.fromJSON({ + encrypted: "hello", + decrypted: "world", + decryptedSerialized: null, + }); + expect(pair.encrypted).toEqual("hello"); + expect(pair.decrypted).toEqual("world"); + }); + + it("should deserialize decryptedSerialized for buffer arrays", () => { + const pair = EncryptionPair.fromJSON({ + encrypted: "encrypted", + decrypted: null, + decryptedSerialized: "hello", + }); + expect(pair.decrypted).toEqual(Utils.fromByteStringToArray("hello").buffer); + }); + }); +}); diff --git a/libs/common/src/models/domain/state.spec.ts b/libs/common/src/models/domain/state.spec.ts new file mode 100644 index 0000000000..64e71d7cb2 --- /dev/null +++ b/libs/common/src/models/domain/state.spec.ts @@ -0,0 +1,28 @@ +import { Account } from "./account"; +import { State } from "./state"; + +describe("state", () => { + describe("fromJSON", () => { + it("should deserialize to an instance of itself", () => { + expect(State.fromJSON({})).toBeInstanceOf(State); + }); + + it("should always assign an object to accounts", () => { + const state = State.fromJSON({}); + expect(state.accounts).not.toBeNull(); + expect(state.accounts).toEqual({}); + }); + + it("should build an account map", () => { + const accountsSpy = jest.spyOn(Account, "fromJSON"); + const state = State.fromJSON({ + accounts: { + userId: {}, + }, + }); + + expect(state.accounts["userId"]).toBeInstanceOf(Account); + expect(accountsSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/common/src/models/domain/state.ts b/libs/common/src/models/domain/state.ts index f5a2c046b5..e5f18bb814 100644 --- a/libs/common/src/models/domain/state.ts +++ b/libs/common/src/models/domain/state.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { Account } from "./account"; import { GlobalState } from "./globalState"; @@ -14,4 +16,26 @@ export class State< constructor(globals: TGlobalState) { this.globals = globals; } + + // TODO, make Jsonify work. It currently doesn't because Globals doesn't implement Jsonify. + static fromJSON( + obj: any + ): State { + return Object.assign(new State(null), obj, { + accounts: State.buildAccountMapFromJSON(obj?.accounts), + }); + } + + private static buildAccountMapFromJSON( + jsonAccounts: Jsonify<{ [userId: string]: Jsonify }> + ) { + if (!jsonAccounts) { + return {}; + } + const accounts: { [userId: string]: Account } = {}; + for (const userId in jsonAccounts) { + accounts[userId] = Account.fromJSON(jsonAccounts[userId]); + } + return accounts; + } } diff --git a/libs/common/src/services/state.service.ts b/libs/common/src/services/state.service.ts index f2c5a5618d..da572b6c25 100644 --- a/libs/common/src/services/state.service.ts +++ b/libs/common/src/services/state.service.ts @@ -10,7 +10,6 @@ import { StorageLocation } from "../enums/storageLocation"; import { ThemeType } from "../enums/themeType"; import { UriMatchType } from "../enums/uriMatchType"; import { StateFactory } from "../factories/stateFactory"; -import { Utils } from "../misc/utils"; import { CipherData } from "../models/data/cipherData"; import { CollectionData } from "../models/data/collectionData"; import { EncryptedOrganizationKeyData } from "../models/data/encryptedOrganizationKeyData"; @@ -148,6 +147,9 @@ export class StateService< return; } await this.updateState(async (state) => { + if (state.accounts == null) { + state.accounts = {}; + } state.accounts[userId] = this.createAccount(); const diskAccount = await this.getAccountFromDisk({ userId: userId }); state.accounts[userId].profile = diskAccount.profile; @@ -494,9 +496,10 @@ export class StateService< @withPrototype(SymmetricCryptoKey, SymmetricCryptoKey.fromJSON) async getCryptoMasterKey(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.keys?.cryptoMasterKey; + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); + return account?.keys?.cryptoMasterKey; } async setCryptoMasterKey(value: SymmetricCryptoKey, options?: StorageOptions): Promise { @@ -657,9 +660,10 @@ export class StateService< @withPrototype(SymmetricCryptoKey, SymmetricCryptoKey.fromJSON) async getDecryptedCryptoSymmetricKey(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.keys?.cryptoSymmetricKey?.decrypted; + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); + return account?.keys?.cryptoSymmetricKey?.decrypted; } async setDecryptedCryptoSymmetricKey( @@ -760,14 +764,9 @@ export class StateService< } async getDecryptedPrivateKey(options?: StorageOptions): Promise { - const privateKey = ( + return ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.keys?.privateKey; - let result = privateKey?.decrypted; - if (result == null && privateKey?.decryptedSerialized != null) { - result = Utils.fromByteStringToArray(privateKey.decryptedSerialized); - } - return result; + )?.keys?.privateKey.decrypted; } async setDecryptedPrivateKey(value: ArrayBuffer, options?: StorageOptions): Promise { @@ -775,8 +774,6 @@ export class StateService< this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.keys.privateKey.decrypted = value; - account.keys.privateKey.decryptedSerialized = - value == null ? null : Utils.fromBufferToByteString(value); await this.saveAccount( account, this.reconcileOptions(options, await this.defaultInMemoryOptions()) @@ -2015,11 +2012,7 @@ export class StateService< const keys = ( await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) )?.keys; - let result = keys?.publicKey; - if (result == null && keys?.publicKeySerialized != null) { - result = Utils.fromByteStringToArray(keys.publicKeySerialized); - } - return result; + return keys?.publicKey; } async setPublicKey(value: ArrayBuffer, options?: StorageOptions): Promise { @@ -2027,7 +2020,6 @@ export class StateService< this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.keys.publicKey = value; - account.keys.publicKeySerialized = value == null ? null : Utils.fromBufferToByteString(value); await this.saveAccount( account, this.reconcileOptions(options, await this.defaultInMemoryOptions()) @@ -2718,8 +2710,11 @@ export class StateService< : await this.secureStorageService.save(`${options.userId}${key}`, value, options); } - protected state(): Promise> { - return this.memoryStorageService.get>(keys.state); + protected async state(): Promise> { + const stateJson = await this.memoryStorageService.get>( + keys.state + ); + return State.fromJSON(stateJson); } private async setState(state: State): Promise { diff --git a/libs/common/src/services/stateMigration.service.ts b/libs/common/src/services/stateMigration.service.ts index 359b4acec4..e936860657 100644 --- a/libs/common/src/services/stateMigration.service.ts +++ b/libs/common/src/services/stateMigration.service.ts @@ -12,7 +12,13 @@ import { OrganizationData } from "../models/data/organizationData"; import { PolicyData } from "../models/data/policyData"; import { ProviderData } from "../models/data/providerData"; import { SendData } from "../models/data/sendData"; -import { Account, AccountSettings, AccountSettingsSettings } from "../models/domain/account"; +import { + Account, + AccountSettings, + AccountSettingsSettings, + EncryptionPair, +} from "../models/domain/account"; +import { EncString } from "../models/domain/encString"; import { EnvironmentUrls } from "../models/domain/environmentUrls"; import { GeneratedPasswordHistory } from "../models/domain/generatedPasswordHistory"; import { GlobalState } from "../models/domain/globalState"; @@ -314,10 +320,10 @@ export class StateMigrationService< passwordGenerationOptions: (await this.get(v1Keys.passwordGenerationOptions)) ?? defaultAccount.settings.passwordGenerationOptions, - pinProtected: { + pinProtected: Object.assign(new EncryptionPair(), { decrypted: null, encrypted: await this.get(v1Keys.pinProtected), - }, + }), protectedPin: await this.get(v1Keys.protectedPin), settings: userId == null