1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-29 12:55:21 +01:00

Add to/fromJSON methods to State and Accounts

This is needed since all storage in manifest v3 is key-value-pair-based
and session storage of most data is actually serialized into an
encrypted string.
This commit is contained in:
Matt Gibson 2022-08-31 17:05:07 -04:00
parent 719a6d095e
commit 77bbd3a863
No known key found for this signature in database
GPG Key ID: A2275080D765C2D7
12 changed files with 366 additions and 31 deletions

View File

@ -116,8 +116,8 @@ describe("State Migration Service", () => {
key: "orgThreeEncKey", key: "orgThreeEncKey",
}, },
}, },
}, } as any,
}, } as any,
}); });
const migratedAccount = await (stateMigrationService as any).migrateAccountFrom4To5( const migratedAccount = await (stateMigrationService as any).migrateAccountFrom4To5(

View File

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

View File

@ -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<string, EncString>({
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");
});
});
});

View File

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

View File

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

View File

@ -1,3 +1,7 @@
import { Jsonify } from "type-fest";
import { Utils } from "@bitwarden/common/misc/utils";
import { AuthenticationStatus } from "../../enums/authenticationStatus"; import { AuthenticationStatus } from "../../enums/authenticationStatus";
import { KdfType } from "../../enums/kdfType"; import { KdfType } from "../../enums/kdfType";
import { UriMatchType } from "../../enums/uriMatchType"; import { UriMatchType } from "../../enums/uriMatchType";
@ -23,7 +27,39 @@ import { SymmetricCryptoKey } from "./symmetricCryptoKey";
export class EncryptionPair<TEncrypted, TDecrypted> { export class EncryptionPair<TEncrypted, TDecrypted> {
encrypted?: TEncrypted; encrypted?: TEncrypted;
decrypted?: TDecrypted; 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<TEncrypted, TDecrypted>(
obj: Jsonify<EncryptionPair<Jsonify<TEncrypted>, Jsonify<TDecrypted>>>,
decryptedFromJson?: (obj: Jsonify<TDecrypted>) => TDecrypted,
encryptedFromJson?: (obj: Jsonify<TEncrypted>) => TEncrypted
) {
const pair = new EncryptionPair<TEncrypted, TDecrypted>();
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<TEncrypted, TDecrypted> { export class DataEncryptionPair<TEncrypted, TDecrypted> {
@ -83,8 +119,50 @@ export class AccountKeys {
>(); >();
privateKey?: EncryptionPair<string, ArrayBuffer> = new EncryptionPair<string, ArrayBuffer>(); privateKey?: EncryptionPair<string, ArrayBuffer> = new EncryptionPair<string, ArrayBuffer>();
publicKey?: ArrayBuffer; publicKey?: ArrayBuffer;
publicKeySerialized?: string; private publicKeySerialized?: string;
apiKeyClientSecret?: 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<string, SymmetricCryptoKey>();
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<string, SymmetricCryptoKey>();
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 { export class AccountProfile {
@ -105,6 +183,10 @@ export class AccountProfile {
keyHash?: string; keyHash?: string;
kdfIterations?: number; kdfIterations?: number;
kdfType?: KdfType; kdfType?: KdfType;
static fromJSON(obj: Jsonify<AccountProfile>): AccountProfile {
return Object.assign(new AccountProfile(), obj);
}
} }
export class AccountSettings { export class AccountSettings {
@ -140,6 +222,15 @@ export class AccountSettings {
settings?: AccountSettingsSettings; // TODO: Merge whatever is going on here into the AccountSettings model properly settings?: AccountSettingsSettings; // TODO: Merge whatever is going on here into the AccountSettings model properly
vaultTimeout?: number; vaultTimeout?: number;
vaultTimeoutAction?: string = "lock"; vaultTimeoutAction?: string = "lock";
static fromJSON(obj: Jsonify<AccountSettings>): AccountSettings {
return Object.assign(new AccountSettings(), obj, {
pinProtected: EncryptionPair.fromJSON<string, EncString>(
obj?.pinProtected,
EncString.fromJSON
),
});
}
} }
export type AccountSettingsSettings = { export type AccountSettingsSettings = {
@ -151,6 +242,10 @@ export class AccountTokens {
decodedToken?: any; decodedToken?: any;
refreshToken?: string; refreshToken?: string;
securityStamp?: string; securityStamp?: string;
static fromJSON(obj: Jsonify<AccountTokens>): AccountTokens {
return Object.assign(new AccountTokens(), obj);
}
} }
export class Account { 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),
});
}
} }

View File

@ -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<string, SymmetricCryptoKey>();
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();
});
});
});

View File

@ -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<string, ArrayBuffer>();
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<string, string>();
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<string, ArrayBuffer>({
encrypted: "encrypted",
decrypted: null,
decryptedSerialized: "hello",
});
expect(pair.decrypted).toEqual(Utils.fromByteStringToArray("hello").buffer);
});
});
});

View File

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

View File

@ -1,3 +1,5 @@
import { Jsonify } from "type-fest";
import { Account } from "./account"; import { Account } from "./account";
import { GlobalState } from "./globalState"; import { GlobalState } from "./globalState";
@ -14,4 +16,26 @@ export class State<
constructor(globals: TGlobalState) { constructor(globals: TGlobalState) {
this.globals = globals; this.globals = globals;
} }
// TODO, make Jsonify<State,TGlobalState,TAccount> work. It currently doesn't because Globals doesn't implement Jsonify.
static fromJSON<TGlobalState extends GlobalState, TAccount extends Account>(
obj: any
): State<TGlobalState, TAccount> {
return Object.assign(new State(null), obj, {
accounts: State.buildAccountMapFromJSON(obj?.accounts),
});
}
private static buildAccountMapFromJSON(
jsonAccounts: Jsonify<{ [userId: string]: Jsonify<Account> }>
) {
if (!jsonAccounts) {
return {};
}
const accounts: { [userId: string]: Account } = {};
for (const userId in jsonAccounts) {
accounts[userId] = Account.fromJSON(jsonAccounts[userId]);
}
return accounts;
}
} }

View File

@ -10,7 +10,6 @@ import { StorageLocation } from "../enums/storageLocation";
import { ThemeType } from "../enums/themeType"; import { ThemeType } from "../enums/themeType";
import { UriMatchType } from "../enums/uriMatchType"; import { UriMatchType } from "../enums/uriMatchType";
import { StateFactory } from "../factories/stateFactory"; import { StateFactory } from "../factories/stateFactory";
import { Utils } from "../misc/utils";
import { CipherData } from "../models/data/cipherData"; import { CipherData } from "../models/data/cipherData";
import { CollectionData } from "../models/data/collectionData"; import { CollectionData } from "../models/data/collectionData";
import { EncryptedOrganizationKeyData } from "../models/data/encryptedOrganizationKeyData"; import { EncryptedOrganizationKeyData } from "../models/data/encryptedOrganizationKeyData";
@ -148,6 +147,9 @@ export class StateService<
return; return;
} }
await this.updateState(async (state) => { await this.updateState(async (state) => {
if (state.accounts == null) {
state.accounts = {};
}
state.accounts[userId] = this.createAccount(); state.accounts[userId] = this.createAccount();
const diskAccount = await this.getAccountFromDisk({ userId: userId }); const diskAccount = await this.getAccountFromDisk({ userId: userId });
state.accounts[userId].profile = diskAccount.profile; state.accounts[userId].profile = diskAccount.profile;
@ -494,9 +496,10 @@ export class StateService<
@withPrototype(SymmetricCryptoKey, SymmetricCryptoKey.fromJSON) @withPrototype(SymmetricCryptoKey, SymmetricCryptoKey.fromJSON)
async getCryptoMasterKey(options?: StorageOptions): Promise<SymmetricCryptoKey> { async getCryptoMasterKey(options?: StorageOptions): Promise<SymmetricCryptoKey> {
return ( const account = await this.getAccount(
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) this.reconcileOptions(options, await this.defaultInMemoryOptions())
)?.keys?.cryptoMasterKey; );
return account?.keys?.cryptoMasterKey;
} }
async setCryptoMasterKey(value: SymmetricCryptoKey, options?: StorageOptions): Promise<void> { async setCryptoMasterKey(value: SymmetricCryptoKey, options?: StorageOptions): Promise<void> {
@ -657,9 +660,10 @@ export class StateService<
@withPrototype(SymmetricCryptoKey, SymmetricCryptoKey.fromJSON) @withPrototype(SymmetricCryptoKey, SymmetricCryptoKey.fromJSON)
async getDecryptedCryptoSymmetricKey(options?: StorageOptions): Promise<SymmetricCryptoKey> { async getDecryptedCryptoSymmetricKey(options?: StorageOptions): Promise<SymmetricCryptoKey> {
return ( const account = await this.getAccount(
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) this.reconcileOptions(options, await this.defaultInMemoryOptions())
)?.keys?.cryptoSymmetricKey?.decrypted; );
return account?.keys?.cryptoSymmetricKey?.decrypted;
} }
async setDecryptedCryptoSymmetricKey( async setDecryptedCryptoSymmetricKey(
@ -760,14 +764,9 @@ export class StateService<
} }
async getDecryptedPrivateKey(options?: StorageOptions): Promise<ArrayBuffer> { async getDecryptedPrivateKey(options?: StorageOptions): Promise<ArrayBuffer> {
const privateKey = ( return (
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
)?.keys?.privateKey; )?.keys?.privateKey.decrypted;
let result = privateKey?.decrypted;
if (result == null && privateKey?.decryptedSerialized != null) {
result = Utils.fromByteStringToArray(privateKey.decryptedSerialized);
}
return result;
} }
async setDecryptedPrivateKey(value: ArrayBuffer, options?: StorageOptions): Promise<void> { async setDecryptedPrivateKey(value: ArrayBuffer, options?: StorageOptions): Promise<void> {
@ -775,8 +774,6 @@ export class StateService<
this.reconcileOptions(options, await this.defaultInMemoryOptions()) this.reconcileOptions(options, await this.defaultInMemoryOptions())
); );
account.keys.privateKey.decrypted = value; account.keys.privateKey.decrypted = value;
account.keys.privateKey.decryptedSerialized =
value == null ? null : Utils.fromBufferToByteString(value);
await this.saveAccount( await this.saveAccount(
account, account,
this.reconcileOptions(options, await this.defaultInMemoryOptions()) this.reconcileOptions(options, await this.defaultInMemoryOptions())
@ -2015,11 +2012,7 @@ export class StateService<
const keys = ( const keys = (
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
)?.keys; )?.keys;
let result = keys?.publicKey; return keys?.publicKey;
if (result == null && keys?.publicKeySerialized != null) {
result = Utils.fromByteStringToArray(keys.publicKeySerialized);
}
return result;
} }
async setPublicKey(value: ArrayBuffer, options?: StorageOptions): Promise<void> { async setPublicKey(value: ArrayBuffer, options?: StorageOptions): Promise<void> {
@ -2027,7 +2020,6 @@ export class StateService<
this.reconcileOptions(options, await this.defaultInMemoryOptions()) this.reconcileOptions(options, await this.defaultInMemoryOptions())
); );
account.keys.publicKey = value; account.keys.publicKey = value;
account.keys.publicKeySerialized = value == null ? null : Utils.fromBufferToByteString(value);
await this.saveAccount( await this.saveAccount(
account, account,
this.reconcileOptions(options, await this.defaultInMemoryOptions()) this.reconcileOptions(options, await this.defaultInMemoryOptions())
@ -2718,8 +2710,11 @@ export class StateService<
: await this.secureStorageService.save(`${options.userId}${key}`, value, options); : await this.secureStorageService.save(`${options.userId}${key}`, value, options);
} }
protected state(): Promise<State<TGlobalState, TAccount>> { protected async state(): Promise<State<TGlobalState, TAccount>> {
return this.memoryStorageService.get<State<TGlobalState, TAccount>>(keys.state); const stateJson = await this.memoryStorageService.get<State<TGlobalState, TAccount>>(
keys.state
);
return State.fromJSON(stateJson);
} }
private async setState(state: State<TGlobalState, TAccount>): Promise<void> { private async setState(state: State<TGlobalState, TAccount>): Promise<void> {

View File

@ -12,7 +12,13 @@ import { OrganizationData } from "../models/data/organizationData";
import { PolicyData } from "../models/data/policyData"; import { PolicyData } from "../models/data/policyData";
import { ProviderData } from "../models/data/providerData"; import { ProviderData } from "../models/data/providerData";
import { SendData } from "../models/data/sendData"; 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 { EnvironmentUrls } from "../models/domain/environmentUrls";
import { GeneratedPasswordHistory } from "../models/domain/generatedPasswordHistory"; import { GeneratedPasswordHistory } from "../models/domain/generatedPasswordHistory";
import { GlobalState } from "../models/domain/globalState"; import { GlobalState } from "../models/domain/globalState";
@ -314,10 +320,10 @@ export class StateMigrationService<
passwordGenerationOptions: passwordGenerationOptions:
(await this.get<any>(v1Keys.passwordGenerationOptions)) ?? (await this.get<any>(v1Keys.passwordGenerationOptions)) ??
defaultAccount.settings.passwordGenerationOptions, defaultAccount.settings.passwordGenerationOptions,
pinProtected: { pinProtected: Object.assign(new EncryptionPair<string, EncString>(), {
decrypted: null, decrypted: null,
encrypted: await this.get<string>(v1Keys.pinProtected), encrypted: await this.get<string>(v1Keys.pinProtected),
}, }),
protectedPin: await this.get<string>(v1Keys.protectedPin), protectedPin: await this.get<string>(v1Keys.protectedPin),
settings: settings:
userId == null userId == null