1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-25 12:15:18 +01:00

Ps 1291/apply to from json pattern to state (#3425)

* Clean up dangling behaviorSubject

* Handle null in utils

* fix null check

* Await promises, even in async functions

* 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.

* Simplify AccountKeys json parsing

* Fix account key (de)serialization

* Remove unused DecodedToken state

* Correct filename typo

* Simplify keys `toJSON` tests

* Explain AccountKeys `toJSON` return type

* Remove unnecessary `any`s

* Remove unique ArrayBuffer serialization

* Initialize items in MemoryStorageService

* Revert "Fix account key (de)serialization"

This reverts commit b1dffb5c2c, which was breaking serializations

* Move fromJSON to owning object

* Add DeepJsonify type

* Use Records for storage

* Add new Account Settings to serialized data

* Fix failing serialization tests

* Extract complex type conversion to helper methods

* Remove unnecessary decorator

* Return null from json deserializers

* Remove unnecessary decorators

* Remove obsolete test

* Use type-fest `Jsonify` formatting rules for external library

* Update jsonify comment

Co-authored-by: @eliykat

* Remove erroneous comment

* Fix unintended deep-jsonify changes

* Fix prettierignore

* Fix formatting of deep-jsonify.ts

Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
This commit is contained in:
Matt Gibson 2022-09-22 07:51:14 -05:00 committed by GitHub
parent 30f38dc916
commit df9e6e21c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 635 additions and 286 deletions

View File

@ -27,3 +27,6 @@ libs/.github
# Github Workflows # Github Workflows
.github/workflows .github/workflows
# Forked library files
libs/common/src/types/deep-jsonify.ts

View File

@ -19,6 +19,7 @@ describe("sessionSync decorator", () => {
ctor: ctor, ctor: ctor,
initializer: initializer, initializer: initializer,
}), }),
testClass.testProperty.complete(),
]); ]);
}); });
}); });

View File

@ -5,6 +5,7 @@ import { BrowserApi } from "../../browser/browserApi";
import { StateService } from "../../services/abstractions/state.service"; import { StateService } from "../../services/abstractions/state.service";
import { SessionSyncer } from "./session-syncer"; import { SessionSyncer } from "./session-syncer";
import { SyncedItemMetadata } from "./sync-item-metadata";
describe("session syncer", () => { describe("session syncer", () => {
const propertyKey = "behaviorSubject"; const propertyKey = "behaviorSubject";
@ -140,12 +141,14 @@ describe("session syncer", () => {
}); });
it("should update from message on emit from another instance", async () => { it("should update from message on emit from another instance", async () => {
const builder = jest.fn();
jest.spyOn(SyncedItemMetadata, "builder").mockReturnValue(builder);
stateService.getFromSessionMemory.mockResolvedValue("test"); stateService.getFromSessionMemory.mockResolvedValue("test");
await sut.updateFromMessage({ command: `${sessionKey}_update`, id: "different_id" }); await sut.updateFromMessage({ command: `${sessionKey}_update`, id: "different_id" });
expect(stateService.getFromSessionMemory).toHaveBeenCalledTimes(1); expect(stateService.getFromSessionMemory).toHaveBeenCalledTimes(1);
expect(stateService.getFromSessionMemory).toHaveBeenCalledWith(sessionKey); expect(stateService.getFromSessionMemory).toHaveBeenCalledWith(sessionKey, builder);
expect(nextSpy).toHaveBeenCalledTimes(1); expect(nextSpy).toHaveBeenCalledTimes(1);
expect(nextSpy).toHaveBeenCalledWith("test"); expect(nextSpy).toHaveBeenCalledWith("test");

View File

@ -66,8 +66,8 @@ export class SessionSyncer {
if (message.command != this.updateMessageCommand || message.id === this.id) { if (message.command != this.updateMessageCommand || message.id === this.id) {
return; return;
} }
const keyValuePair = await this.stateService.getFromSessionMemory(this.metaData.sessionKey); const builder = SyncedItemMetadata.builder(this.metaData);
const value = SyncedItemMetadata.buildFromKeyValuePair(keyValuePair, this.metaData); const value = await this.stateService.getFromSessionMemory(this.metaData.sessionKey, builder);
this.ignoreNextUpdate = true; this.ignoreNextUpdate = true;
this.behaviorSubject.next(value); this.behaviorSubject.next(value);
} }

View File

@ -5,19 +5,15 @@ export class SyncedItemMetadata {
initializer?: (keyValuePair: any) => any; initializer?: (keyValuePair: any) => any;
initializeAsArray?: boolean; initializeAsArray?: boolean;
static buildFromKeyValuePair(keyValuePair: any, metadata: SyncedItemMetadata): any { static builder(metadata: SyncedItemMetadata): (o: any) => any {
const builder = SyncedItemMetadata.getBuilder(metadata); const itemBuilder =
metadata.initializer != null
if (metadata.initializeAsArray) {
return keyValuePair.map((o: any) => builder(o));
} else {
return builder(keyValuePair);
}
}
private static getBuilder(metadata: SyncedItemMetadata): (o: any) => any {
return metadata.initializer != null
? metadata.initializer ? metadata.initializer
: (o: any) => Object.assign(new metadata.ctor(), o); : (o: any) => Object.assign(new metadata.ctor(), o);
if (metadata.initializeAsArray) {
return (keyValuePair: any) => keyValuePair.map((o: any) => itemBuilder(o));
} else {
return (keyValuePair: any) => itemBuilder(keyValuePair);
}
} }
} }

View File

@ -1,59 +1,39 @@
import { SyncedItemMetadata } from "./sync-item-metadata"; import { SyncedItemMetadata } from "./sync-item-metadata";
describe("build from key value pair", () => { describe("builder", () => {
const propertyKey = "propertyKey"; const propertyKey = "propertyKey";
const key = "key"; const key = "key";
const initializer = (s: any) => "used initializer"; const initializer = (s: any) => "used initializer";
class TestClass {} class TestClass {}
const ctor = TestClass; const ctor = TestClass;
it("should call initializer if provided", () => { it("should use initializer if provided", () => {
const actual = SyncedItemMetadata.buildFromKeyValuePair( const metadata = { propertyKey, sessionKey: key, initializer };
{}, const builder = SyncedItemMetadata.builder(metadata);
{ expect(builder({})).toBe("used initializer");
propertyKey,
sessionKey: "key",
initializer: initializer,
}
);
expect(actual).toEqual("used initializer");
}); });
it("should call ctor if provided", () => { it("should use ctor if initializer is not provided", () => {
const expected = { provided: "value" }; const metadata = { propertyKey, sessionKey: key, ctor };
const actual = SyncedItemMetadata.buildFromKeyValuePair(expected, { const builder = SyncedItemMetadata.builder(metadata);
propertyKey, expect(builder({})).toBeInstanceOf(TestClass);
sessionKey: key,
ctor: ctor,
}); });
expect(actual).toBeInstanceOf(ctor); it("should prefer initializer over ctor", () => {
expect(actual).toEqual(expect.objectContaining(expected)); const metadata = { propertyKey, sessionKey: key, ctor, initializer };
}); const builder = SyncedItemMetadata.builder(metadata);
expect(builder({})).toBe("used initializer");
it("should prefer using initializer if both are provided", () => {
const actual = SyncedItemMetadata.buildFromKeyValuePair(
{},
{
propertyKey,
sessionKey: key,
initializer: initializer,
ctor: ctor,
}
);
expect(actual).toEqual("used initializer");
}); });
it("should honor initialize as array", () => { it("should honor initialize as array", () => {
const actual = SyncedItemMetadata.buildFromKeyValuePair([1, 2], { const metadata = {
propertyKey, propertyKey,
sessionKey: key, sessionKey: key,
initializer: initializer, initializer: initializer,
initializeAsArray: true, initializeAsArray: true,
}); };
const builder = SyncedItemMetadata.builder(metadata);
expect(actual).toEqual(["used initializer", "used initializer"]); expect(builder([{}])).toBeInstanceOf(Array);
expect(builder([{}])[0]).toBe("used initializer");
}); });
}); });

View File

@ -1,3 +1,5 @@
import { Jsonify } from "type-fest";
import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/abstractions/state.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/abstractions/state.service";
import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions"; import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions";
@ -7,7 +9,7 @@ import { BrowserGroupingsComponentState } from "src/models/browserGroupingsCompo
import { BrowserSendComponentState } from "src/models/browserSendComponentState"; import { BrowserSendComponentState } from "src/models/browserSendComponentState";
export abstract class StateService extends BaseStateServiceAbstraction<Account> { export abstract class StateService extends BaseStateServiceAbstraction<Account> {
abstract getFromSessionMemory<T>(key: string): Promise<T>; abstract getFromSessionMemory<T>(key: string, deserializer?: (obj: Jsonify<T>) => T): Promise<T>;
abstract setInSessionMemory(key: string, value: any): Promise<void>; abstract setInSessionMemory(key: string, value: any): Promise<void>;
getBrowserGroupingComponentState: ( getBrowserGroupingComponentState: (
options?: StorageOptions options?: StorageOptions

View File

@ -96,6 +96,13 @@ describe("Browser Session Storage Service", () => {
expect(cache.has("test")).toBe(true); expect(cache.has("test")).toBe(true);
expect(cache.get("test")).toEqual(session.test); expect(cache.get("test")).toEqual(session.test);
}); });
it("should use a deserializer if provided", async () => {
const deserializer = jest.fn().mockReturnValue(testObj);
const result = await sut.get("test", { deserializer: deserializer });
expect(deserializer).toHaveBeenCalledWith(session.test);
expect(result).toEqual(testObj);
});
}); });
}); });
}); });

View File

@ -1,6 +1,12 @@
import { Jsonify } from "type-fest";
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service"; import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service";
import { AbstractCachedStorageService } from "@bitwarden/common/abstractions/storage.service"; import {
AbstractCachedStorageService,
MemoryStorageServiceInterface,
} from "@bitwarden/common/abstractions/storage.service";
import { EncString } from "@bitwarden/common/models/domain/encString"; import { EncString } from "@bitwarden/common/models/domain/encString";
import { MemoryStorageOptions } from "@bitwarden/common/models/domain/storageOptions";
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey";
import { devFlag } from "../decorators/dev-flag.decorator"; import { devFlag } from "../decorators/dev-flag.decorator";
@ -15,7 +21,10 @@ const keys = {
sessionKey: "session", sessionKey: "session",
}; };
export class LocalBackedSessionStorageService extends AbstractCachedStorageService { export class LocalBackedSessionStorageService
extends AbstractCachedStorageService
implements MemoryStorageServiceInterface
{
private cache = new Map<string, unknown>(); private cache = new Map<string, unknown>();
private localStorage = new BrowserLocalStorageService(); private localStorage = new BrowserLocalStorageService();
private sessionStorage = new BrowserMemoryStorageService(); private sessionStorage = new BrowserMemoryStorageService();
@ -27,21 +36,26 @@ export class LocalBackedSessionStorageService extends AbstractCachedStorageServi
super(); super();
} }
async get<T>(key: string): Promise<T> { async get<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> {
if (this.cache.has(key)) { if (this.cache.has(key)) {
return this.cache.get(key) as T; return this.cache.get(key) as T;
} }
return await this.getBypassCache(key); return await this.getBypassCache(key, options);
} }
async getBypassCache<T>(key: string): Promise<T> { async getBypassCache<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T> {
const session = await this.getLocalSession(await this.getSessionEncKey()); const session = await this.getLocalSession(await this.getSessionEncKey());
if (session == null || !Object.keys(session).includes(key)) { if (session == null || !Object.keys(session).includes(key)) {
return null; return null;
} }
this.cache.set(key, session[key]); let value = session[key];
if (options?.deserializer != null) {
value = options.deserializer(value as Jsonify<T>);
}
this.cache.set(key, value);
return this.cache.get(key) as T; return this.cache.get(key) as T;
} }

View File

@ -1,8 +1,8 @@
import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { LogService } from "@bitwarden/common/abstractions/log.service"; import { LogService } from "@bitwarden/common/abstractions/log.service";
import { import {
AbstractCachedStorageService, MemoryStorageServiceInterface,
AbstractStorageService, AbstractStorageService,
} from "@bitwarden/common/abstractions/storage.service"; } from "@bitwarden/common/abstractions/storage.service";
import { SendType } from "@bitwarden/common/enums/sendType"; import { SendType } from "@bitwarden/common/enums/sendType";
@ -49,7 +49,7 @@ describe("Browser State Service", () => {
}); });
describe("direct memory storage access", () => { describe("direct memory storage access", () => {
let memoryStorageService: AbstractCachedStorageService; let memoryStorageService: LocalBackedSessionStorageService;
beforeEach(() => { beforeEach(() => {
// We need `AbstractCachedStorageService` in the prototype chain to correctly test cache bypass. // We need `AbstractCachedStorageService` in the prototype chain to correctly test cache bypass.
@ -79,12 +79,12 @@ describe("Browser State Service", () => {
}); });
describe("state methods", () => { describe("state methods", () => {
let memoryStorageService: SubstituteOf<AbstractStorageService>; let memoryStorageService: SubstituteOf<AbstractStorageService & MemoryStorageServiceInterface>;
beforeEach(() => { beforeEach(() => {
memoryStorageService = Substitute.for(); memoryStorageService = Substitute.for();
const stateGetter = (key: string) => Promise.resolve(JSON.parse(JSON.stringify(state))); const stateGetter = (key: string) => Promise.resolve(JSON.parse(JSON.stringify(state)));
memoryStorageService.get("state").mimicks(stateGetter); memoryStorageService.get("state", Arg.any()).mimicks(stateGetter);
sut = new StateService( sut = new StateService(
diskStorageService, diskStorageService,

View File

@ -1,3 +1,5 @@
import { Jsonify } from "type-fest";
import { AbstractCachedStorageService } from "@bitwarden/common/abstractions/storage.service"; import { AbstractCachedStorageService } from "@bitwarden/common/abstractions/storage.service";
import { GlobalState } from "@bitwarden/common/models/domain/globalState"; import { GlobalState } from "@bitwarden/common/models/domain/globalState";
import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions"; import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions";
@ -17,9 +19,9 @@ export class StateService
extends BaseStateService<GlobalState, Account> extends BaseStateService<GlobalState, Account>
implements StateServiceAbstraction implements StateServiceAbstraction
{ {
async getFromSessionMemory<T>(key: string): Promise<T> { async getFromSessionMemory<T>(key: string, deserializer?: (obj: Jsonify<T>) => T): Promise<T> {
return this.memoryStorageService instanceof AbstractCachedStorageService return this.memoryStorageService instanceof AbstractCachedStorageService
? await this.memoryStorageService.getBypassCache<T>(key) ? await this.memoryStorageService.getBypassCache<T>(key, { deserializer: deserializer })
: await this.memoryStorageService.get<T>(key); : await this.memoryStorageService.get<T>(key);
} }

View File

@ -70,4 +70,10 @@ describe("Utils Service", () => {
expect(Utils.newGuid()).toMatch(validGuid); expect(Utils.newGuid()).toMatch(validGuid);
}); });
}); });
describe("fromByteStringToArray", () => {
it("should handle null", () => {
expect(Utils.fromByteStringToArray(null)).toEqual(null);
});
});
}); });

View File

@ -1,82 +0,0 @@
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
import { LogService } from "@bitwarden/common/abstractions/log.service";
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
import { Account } from "@bitwarden/common/models/domain/account";
import { GlobalState } from "@bitwarden/common/models/domain/globalState";
import { State } from "@bitwarden/common/models/domain/state";
import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions";
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey";
import { StateService } from "@bitwarden/common/services/state.service";
import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service";
describe("Browser State Service backed by chrome.storage api", () => {
let secureStorageService: SubstituteOf<AbstractStorageService>;
let diskStorageService: SubstituteOf<AbstractStorageService>;
let memoryStorageService: SubstituteOf<AbstractStorageService>;
let logService: SubstituteOf<LogService>;
let stateMigrationService: SubstituteOf<StateMigrationService>;
let stateFactory: SubstituteOf<StateFactory<GlobalState, Account>>;
let useAccountCache: boolean;
let state: State<GlobalState, Account>;
const userId = "userId";
let sut: StateService;
beforeEach(() => {
secureStorageService = Substitute.for();
diskStorageService = Substitute.for();
memoryStorageService = Substitute.for();
logService = Substitute.for();
stateMigrationService = Substitute.for();
stateFactory = Substitute.for();
useAccountCache = true;
state = new State(new GlobalState());
const stateGetter = (key: string) => Promise.resolve(JSON.parse(JSON.stringify(state)));
memoryStorageService.get("state").mimicks(stateGetter);
memoryStorageService
.save("state", Arg.any(), Arg.any())
.mimicks((key: string, obj: any, options: StorageOptions) => {
return new Promise(() => {
state = obj;
});
});
sut = new StateService(
diskStorageService,
secureStorageService,
memoryStorageService,
logService,
stateMigrationService,
stateFactory,
useAccountCache
);
});
describe("account state getters", () => {
beforeEach(() => {
state.accounts[userId] = createAccount(userId);
state.activeUserId = userId;
});
describe("getCryptoMasterKey", () => {
it("should return the stored SymmetricCryptoKey", async () => {
const key = new SymmetricCryptoKey(new Uint8Array(32).buffer);
state.accounts[userId].keys.cryptoMasterKey = key;
const actual = await sut.getCryptoMasterKey();
expect(actual).toBeInstanceOf(SymmetricCryptoKey);
expect(actual).toMatchObject(key);
});
});
});
function createAccount(userId: string): Account {
return new Account({
profile: { userId: userId },
});
}
});

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

@ -78,8 +78,6 @@ export abstract class StateService<T extends Account = Account> {
getCryptoMasterKeyBiometric: (options?: StorageOptions) => Promise<string>; getCryptoMasterKeyBiometric: (options?: StorageOptions) => Promise<string>;
hasCryptoMasterKeyBiometric: (options?: StorageOptions) => Promise<boolean>; hasCryptoMasterKeyBiometric: (options?: StorageOptions) => Promise<boolean>;
setCryptoMasterKeyBiometric: (value: string, options?: StorageOptions) => Promise<void>; setCryptoMasterKeyBiometric: (value: string, options?: StorageOptions) => Promise<void>;
getDecodedToken: (options?: StorageOptions) => Promise<any>;
setDecodedToken: (value: any, options?: StorageOptions) => Promise<void>;
getDecryptedCiphers: (options?: StorageOptions) => Promise<CipherView[]>; getDecryptedCiphers: (options?: StorageOptions) => Promise<CipherView[]>;
setDecryptedCiphers: (value: CipherView[], options?: StorageOptions) => Promise<void>; setDecryptedCiphers: (value: CipherView[], options?: StorageOptions) => Promise<void>;
getDecryptedCollections: (options?: StorageOptions) => Promise<CollectionView[]>; getDecryptedCollections: (options?: StorageOptions) => Promise<CollectionView[]>;

View File

@ -1,4 +1,4 @@
import { StorageOptions } from "../models/domain/storageOptions"; import { MemoryStorageOptions, StorageOptions } from "../models/domain/storageOptions";
export abstract class AbstractStorageService { export abstract class AbstractStorageService {
abstract get<T>(key: string, options?: StorageOptions): Promise<T>; abstract get<T>(key: string, options?: StorageOptions): Promise<T>;
@ -8,5 +8,9 @@ export abstract class AbstractStorageService {
} }
export abstract class AbstractCachedStorageService extends AbstractStorageService { export abstract class AbstractCachedStorageService extends AbstractStorageService {
abstract getBypassCache<T>(key: string, options?: StorageOptions): Promise<T>; abstract getBypassCache<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T>;
}
export interface MemoryStorageServiceInterface {
get<T>(key: string, options?: MemoryStorageOptions<T>): Promise<T>;
} }

View File

@ -99,6 +99,9 @@ export class Utils {
} }
static fromByteStringToArray(str: string): Uint8Array { static fromByteStringToArray(str: string): Uint8Array {
if (str == null) {
return null;
}
const arr = new Uint8Array(str.length); const arr = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
arr[i] = str.charCodeAt(i); arr[i] = str.charCodeAt(i);

View File

@ -0,0 +1,55 @@
import {
EnvironmentServerConfigData,
ServerConfigData,
ThirdPartyServerConfigData,
} from "./server-config.data";
describe("ServerConfigData", () => {
describe("fromJSON", () => {
it("should create a ServerConfigData from a JSON object", () => {
const serverConfigData = ServerConfigData.fromJSON({
version: "1.0.0",
gitHash: "1234567890",
server: {
name: "test",
url: "https://test.com",
},
environment: {
vault: "https://vault.com",
api: "https://api.com",
identity: "https://identity.com",
notifications: "https://notifications.com",
sso: "https://sso.com",
},
utcDate: "2020-01-01T00:00:00.000Z",
});
expect(serverConfigData.version).toEqual("1.0.0");
expect(serverConfigData.gitHash).toEqual("1234567890");
expect(serverConfigData.server.name).toEqual("test");
expect(serverConfigData.server.url).toEqual("https://test.com");
expect(serverConfigData.environment.vault).toEqual("https://vault.com");
expect(serverConfigData.environment.api).toEqual("https://api.com");
expect(serverConfigData.environment.identity).toEqual("https://identity.com");
expect(serverConfigData.environment.notifications).toEqual("https://notifications.com");
expect(serverConfigData.environment.sso).toEqual("https://sso.com");
expect(serverConfigData.utcDate).toEqual("2020-01-01T00:00:00.000Z");
});
it("should be an instance of ServerConfigData", () => {
const serverConfigData = ServerConfigData.fromJSON({} as any);
expect(serverConfigData).toBeInstanceOf(ServerConfigData);
});
it("should deserialize sub objects", () => {
const serverConfigData = ServerConfigData.fromJSON({
server: {},
environment: {},
} as any);
expect(serverConfigData.server).toBeInstanceOf(ThirdPartyServerConfigData);
expect(serverConfigData.environment).toBeInstanceOf(EnvironmentServerConfigData);
});
});
});

View File

@ -1,3 +1,5 @@
import { Jsonify } from "type-fest";
import { import {
ServerConfigResponse, ServerConfigResponse,
ThirdPartyServerConfigResponse, ThirdPartyServerConfigResponse,
@ -11,27 +13,38 @@ export class ServerConfigData {
environment?: EnvironmentServerConfigData; environment?: EnvironmentServerConfigData;
utcDate: string; utcDate: string;
constructor(serverConfigReponse: ServerConfigResponse) { constructor(serverConfigResponse: Partial<ServerConfigResponse>) {
this.version = serverConfigReponse?.version; this.version = serverConfigResponse?.version;
this.gitHash = serverConfigReponse?.gitHash; this.gitHash = serverConfigResponse?.gitHash;
this.server = serverConfigReponse?.server this.server = serverConfigResponse?.server
? new ThirdPartyServerConfigData(serverConfigReponse.server) ? new ThirdPartyServerConfigData(serverConfigResponse.server)
: null; : null;
this.utcDate = new Date().toISOString(); this.utcDate = new Date().toISOString();
this.environment = serverConfigReponse?.environment this.environment = serverConfigResponse?.environment
? new EnvironmentServerConfigData(serverConfigReponse.environment) ? new EnvironmentServerConfigData(serverConfigResponse.environment)
: null; : null;
} }
static fromJSON(obj: Jsonify<ServerConfigData>): ServerConfigData {
return Object.assign(new ServerConfigData({}), obj, {
server: obj?.server ? ThirdPartyServerConfigData.fromJSON(obj.server) : null,
environment: obj?.environment ? EnvironmentServerConfigData.fromJSON(obj.environment) : null,
});
}
} }
export class ThirdPartyServerConfigData { export class ThirdPartyServerConfigData {
name: string; name: string;
url: string; url: string;
constructor(response: ThirdPartyServerConfigResponse) { constructor(response: Partial<ThirdPartyServerConfigResponse>) {
this.name = response.name; this.name = response.name;
this.url = response.url; this.url = response.url;
} }
static fromJSON(obj: Jsonify<ThirdPartyServerConfigData>): ThirdPartyServerConfigData {
return Object.assign(new ThirdPartyServerConfigData({}), obj);
}
} }
export class EnvironmentServerConfigData { export class EnvironmentServerConfigData {
@ -41,11 +54,15 @@ export class EnvironmentServerConfigData {
notifications: string; notifications: string;
sso: string; sso: string;
constructor(response: EnvironmentServerConfigResponse) { constructor(response: Partial<EnvironmentServerConfigResponse>) {
this.vault = response.vault; this.vault = response.vault;
this.api = response.api; this.api = response.api;
this.identity = response.identity; this.identity = response.identity;
this.notifications = response.notifications; this.notifications = response.notifications;
this.sso = response.sso; this.sso = response.sso;
} }
static fromJSON(obj: Jsonify<EnvironmentServerConfigData>): EnvironmentServerConfigData {
return Object.assign(new EnvironmentServerConfigData({}), obj);
}
} }

View File

@ -0,0 +1,62 @@
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;
keys.publicKey = buffer;
const bufferSpy = jest.spyOn(Utils, "fromBufferToByteString");
keys.toJSON();
expect(bufferSpy).toHaveBeenCalledWith(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('"publicKey":"hello"');
});
});
describe("fromJSON", () => {
it("should deserialize public key to a buffer", () => {
const keys = AccountKeys.fromJSON({
publicKey: "hello",
});
expect(keys.publicKey).toEqual(Utils.fromByteStringToArray("hello").buffer);
});
it("should deserialize cryptoMasterKey", () => {
const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON");
AccountKeys.fromJSON({} as any);
expect(spy).toHaveBeenCalled();
});
it("should deserialize organizationKeys", () => {
const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON");
AccountKeys.fromJSON({ organizationKeys: [{ orgId: "keyJSON" }] } as any);
expect(spy).toHaveBeenCalled();
});
it("should deserialize providerKeys", () => {
const spy = jest.spyOn(SymmetricCryptoKey, "fromJSON");
AccountKeys.fromJSON({ providerKeys: [{ providerId: "keyJSON" }] } as any);
expect(spy).toHaveBeenCalled();
});
it("should deserialize privateKey", () => {
const spy = jest.spyOn(EncryptionPair, "fromJSON");
AccountKeys.fromJSON({
privateKey: { encrypted: "encrypted", decrypted: "decrypted" },
} as any);
expect(spy).toHaveBeenCalled();
});
});
});

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,24 @@
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",
});
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,8 @@
import { Except, Jsonify } from "type-fest";
import { Utils } from "@bitwarden/common/misc/utils";
import { DeepJsonify } from "@bitwarden/common/types/deep-jsonify";
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";
@ -24,7 +29,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;
toJSON() {
return {
encrypted: this.encrypted,
decrypted:
this.decrypted instanceof ArrayBuffer
? Utils.fromBufferToByteString(this.decrypted)
: this.decrypted,
};
}
static fromJSON<TEncrypted, TDecrypted>(
obj: Jsonify<EncryptionPair<Jsonify<TEncrypted>, Jsonify<TDecrypted>>>,
decryptedFromJson?: (decObj: Jsonify<TDecrypted> | string) => TDecrypted,
encryptedFromJson?: (encObj: Jsonify<TEncrypted>) => TEncrypted
) {
if (obj == null) {
return null;
}
const pair = new EncryptionPair<TEncrypted, TDecrypted>();
if (obj?.encrypted != null) {
pair.encrypted = encryptedFromJson
? encryptedFromJson(obj.encrypted)
: (obj.encrypted as TEncrypted);
}
if (obj?.decrypted != null) {
pair.decrypted = decryptedFromJson
? decryptedFromJson(obj.decrypted)
: (obj.decrypted as TDecrypted);
}
return pair;
}
} }
export class DataEncryptionPair<TEncrypted, TDecrypted> { export class DataEncryptionPair<TEncrypted, TDecrypted> {
@ -73,19 +110,66 @@ export class AccountKeys {
>(); >();
organizationKeys?: EncryptionPair< organizationKeys?: EncryptionPair<
{ [orgId: string]: EncryptedOrganizationKeyData }, { [orgId: string]: EncryptedOrganizationKeyData },
Map<string, SymmetricCryptoKey> Record<string, SymmetricCryptoKey>
> = new EncryptionPair< > = new EncryptionPair<
{ [orgId: string]: EncryptedOrganizationKeyData }, { [orgId: string]: EncryptedOrganizationKeyData },
Map<string, SymmetricCryptoKey> Record<string, SymmetricCryptoKey>
>(); >();
providerKeys?: EncryptionPair<any, Map<string, SymmetricCryptoKey>> = new EncryptionPair< providerKeys?: EncryptionPair<any, Record<string, SymmetricCryptoKey>> = new EncryptionPair<
any, any,
Map<string, SymmetricCryptoKey> Record<string, SymmetricCryptoKey>
>(); >();
privateKey?: EncryptionPair<string, ArrayBuffer> = new EncryptionPair<string, ArrayBuffer>(); privateKey?: EncryptionPair<string, ArrayBuffer> = new EncryptionPair<string, ArrayBuffer>();
publicKey?: ArrayBuffer; publicKey?: ArrayBuffer;
publicKeySerialized?: string;
apiKeyClientSecret?: string; apiKeyClientSecret?: string;
toJSON() {
return Object.assign(this as Except<AccountKeys, "publicKey">, {
publicKey: Utils.fromBufferToByteString(this.publicKey),
});
}
static fromJSON(obj: DeepJsonify<AccountKeys>): AccountKeys {
if (obj == null) {
return null;
}
return Object.assign(
new AccountKeys(),
{ cryptoMasterKey: SymmetricCryptoKey.fromJSON(obj?.cryptoMasterKey) },
{
cryptoSymmetricKey: EncryptionPair.fromJSON(
obj?.cryptoSymmetricKey,
SymmetricCryptoKey.fromJSON
),
},
{ organizationKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.organizationKeys) },
{ providerKeys: AccountKeys.initRecordEncryptionPairsFromJSON(obj?.providerKeys) },
{
privateKey: EncryptionPair.fromJSON<string, ArrayBuffer>(
obj?.privateKey,
(decObj: string) => Utils.fromByteStringToArray(decObj).buffer
),
},
{
publicKey: Utils.fromByteStringToArray(obj?.publicKey)?.buffer,
}
);
}
static initRecordEncryptionPairsFromJSON(obj: any) {
return EncryptionPair.fromJSON(obj, (decObj: any) => {
if (obj == null) {
return null;
}
const record: Record<string, SymmetricCryptoKey> = {};
for (const id in decObj) {
record[id] = SymmetricCryptoKey.fromJSON(decObj[id]);
}
return record;
});
}
} }
export class AccountProfile { export class AccountProfile {
@ -106,6 +190,14 @@ export class AccountProfile {
keyHash?: string; keyHash?: string;
kdfIterations?: number; kdfIterations?: number;
kdfType?: KdfType; kdfType?: KdfType;
static fromJSON(obj: Jsonify<AccountProfile>): AccountProfile {
if (obj == null) {
return null;
}
return Object.assign(new AccountProfile(), obj);
}
} }
export class AccountSettings { export class AccountSettings {
@ -142,6 +234,21 @@ export class AccountSettings {
vaultTimeout?: number; vaultTimeout?: number;
vaultTimeoutAction?: string = "lock"; vaultTimeoutAction?: string = "lock";
serverConfig?: ServerConfigData; serverConfig?: ServerConfigData;
static fromJSON(obj: Jsonify<AccountSettings>): AccountSettings {
if (obj == null) {
return null;
}
return Object.assign(new AccountSettings(), obj, {
environmentUrls: EnvironmentUrls.fromJSON(obj?.environmentUrls),
pinProtected: EncryptionPair.fromJSON<string, EncString>(
obj?.pinProtected,
EncString.fromJSON
),
serverConfig: ServerConfigData.fromJSON(obj?.serverConfig),
});
}
} }
export type AccountSettingsSettings = { export type AccountSettingsSettings = {
@ -150,9 +257,16 @@ export type AccountSettingsSettings = {
export class AccountTokens { export class AccountTokens {
accessToken?: string; accessToken?: string;
decodedToken?: any;
refreshToken?: string; refreshToken?: string;
securityStamp?: string; securityStamp?: string;
static fromJSON(obj: Jsonify<AccountTokens>): AccountTokens {
if (obj == null) {
return null;
}
return Object.assign(new AccountTokens(), obj);
}
} }
export class Account { export class Account {
@ -186,4 +300,17 @@ export class Account {
}, },
}); });
} }
static fromJSON(json: Jsonify<Account>): Account {
if (json == null) {
return null;
}
return Object.assign(new Account({}), json, {
keys: AccountKeys.fromJSON(json?.keys),
profile: AccountProfile.fromJSON(json?.profile),
settings: AccountSettings.fromJSON(json?.settings),
tokens: AccountTokens.fromJSON(json?.tokens),
});
}
} }

View File

@ -0,0 +1,34 @@
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.decrypted).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",
});
expect(pair.encrypted).toEqual("hello");
expect(pair.decrypted).toEqual("world");
});
});
});

View File

@ -1,3 +1,5 @@
import { Jsonify } from "type-fest";
export class EnvironmentUrls { export class EnvironmentUrls {
base: string = null; base: string = null;
api: string = null; api: string = null;
@ -7,4 +9,8 @@ export class EnvironmentUrls {
events: string = null; events: string = null;
webVault: string = null; webVault: string = null;
keyConnector: string = null; keyConnector: string = null;
static fromJSON(obj: Jsonify<EnvironmentUrls>): EnvironmentUrls {
return Object.assign(new EnvironmentUrls(), obj);
}
} }

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,30 @@ 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> {
if (obj == null) {
return null;
}
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

@ -1,3 +1,5 @@
import { Jsonify } from "type-fest";
import { HtmlStorageLocation } from "../../enums/htmlStorageLocation"; import { HtmlStorageLocation } from "../../enums/htmlStorageLocation";
import { StorageLocation } from "../../enums/storageLocation"; import { StorageLocation } from "../../enums/storageLocation";
@ -8,3 +10,5 @@ export type StorageOptions = {
htmlStorageLocation?: HtmlStorageLocation; htmlStorageLocation?: HtmlStorageLocation;
keySuffix?: string; keySuffix?: string;
}; };
export type MemoryStorageOptions<T> = StorageOptions & { deserializer?: (obj: Jsonify<T>) => T };

View File

@ -393,7 +393,7 @@ export class CipherService implements CipherServiceAbstraction {
: firstValueFrom(this.settingsService.settings$).then( : firstValueFrom(this.settingsService.settings$).then(
(settings: AccountSettingsSettings) => { (settings: AccountSettingsSettings) => {
let matches: any[] = []; let matches: any[] = [];
settings.equivalentDomains.forEach((eqDomain: any) => { settings.equivalentDomains?.forEach((eqDomain: any) => {
if (eqDomain.length && eqDomain.indexOf(domain) >= 0) { if (eqDomain.length && eqDomain.indexOf(domain) >= 0) {
matches = matches.concat(eqDomain); matches = matches.concat(eqDomain);
} }

View File

@ -98,7 +98,7 @@ export class EncryptService implements AbstractEncryptService {
} }
} }
return this.cryptoFunctionService.aesDecryptFast(fastParams); return await this.cryptoFunctionService.aesDecryptFast(fastParams);
} }
async decryptToBytes(encThing: IEncrypted, key: SymmetricCryptoKey): Promise<ArrayBuffer> { async decryptToBytes(encThing: IEncrypted, key: SymmetricCryptoKey): Promise<ArrayBuffer> {

View File

@ -1,6 +1,12 @@
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import {
AbstractStorageService,
MemoryStorageServiceInterface,
} from "@bitwarden/common/abstractions/storage.service";
export class MemoryStorageService implements AbstractStorageService { export class MemoryStorageService
extends AbstractStorageService
implements MemoryStorageServiceInterface
{
private store = new Map<string, any>(); private store = new Map<string, any>();
get<T>(key: string): Promise<T> { get<T>(key: string): Promise<T> {

View File

@ -3,14 +3,16 @@ import { BehaviorSubject, concatMap } from "rxjs";
import { LogService } from "../abstractions/log.service"; import { LogService } from "../abstractions/log.service";
import { StateService as StateServiceAbstraction } from "../abstractions/state.service"; import { StateService as StateServiceAbstraction } from "../abstractions/state.service";
import { StateMigrationService } from "../abstractions/stateMigration.service"; import { StateMigrationService } from "../abstractions/stateMigration.service";
import { AbstractStorageService } from "../abstractions/storage.service"; import {
MemoryStorageServiceInterface,
AbstractStorageService,
} from "../abstractions/storage.service";
import { HtmlStorageLocation } from "../enums/htmlStorageLocation"; import { HtmlStorageLocation } from "../enums/htmlStorageLocation";
import { KdfType } from "../enums/kdfType"; import { KdfType } from "../enums/kdfType";
import { StorageLocation } from "../enums/storageLocation"; 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";
@ -76,7 +78,7 @@ export class StateService<
constructor( constructor(
protected storageService: AbstractStorageService, protected storageService: AbstractStorageService,
protected secureStorageService: AbstractStorageService, protected secureStorageService: AbstractStorageService,
protected memoryStorageService: AbstractStorageService, protected memoryStorageService: AbstractStorageService & MemoryStorageServiceInterface,
protected logService: LogService, protected logService: LogService,
protected stateMigrationService: StateMigrationService, protected stateMigrationService: StateMigrationService,
protected stateFactory: StateFactory<TGlobalState, TAccount>, protected stateFactory: StateFactory<TGlobalState, TAccount>,
@ -150,6 +152,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,11 +499,11 @@ export class StateService<
); );
} }
@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> {
@ -604,23 +609,6 @@ export class StateService<
await this.saveSecureStorageKey(partialKeys.biometricKey, value, options); await this.saveSecureStorageKey(partialKeys.biometricKey, value, options);
} }
async getDecodedToken(options?: StorageOptions): Promise<any> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
)?.tokens?.decodedToken;
}
async setDecodedToken(value: any, options?: StorageOptions): Promise<void> {
const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions())
);
account.tokens.decodedToken = value;
await this.saveAccount(
account,
this.reconcileOptions(options, await this.defaultInMemoryOptions())
);
}
@withPrototypeForArrayMembers(CipherView, CipherView.fromJSON) @withPrototypeForArrayMembers(CipherView, CipherView.fromJSON)
async getDecryptedCiphers(options?: StorageOptions): Promise<CipherView[]> { async getDecryptedCiphers(options?: StorageOptions): Promise<CipherView[]> {
return ( return (
@ -657,11 +645,11 @@ export class StateService<
); );
} }
@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(
@ -678,14 +666,13 @@ export class StateService<
); );
} }
@withPrototypeForMap(SymmetricCryptoKey, SymmetricCryptoKey.fromJSON)
async getDecryptedOrganizationKeys( async getDecryptedOrganizationKeys(
options?: StorageOptions options?: StorageOptions
): Promise<Map<string, SymmetricCryptoKey>> { ): Promise<Map<string, SymmetricCryptoKey>> {
const account = await this.getAccount( const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions()) this.reconcileOptions(options, await this.defaultInMemoryOptions())
); );
return account?.keys?.organizationKeys?.decrypted; return this.recordToMap(account?.keys?.organizationKeys?.decrypted);
} }
async setDecryptedOrganizationKeys( async setDecryptedOrganizationKeys(
@ -695,7 +682,7 @@ export class StateService<
const account = await this.getAccount( const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions()) this.reconcileOptions(options, await this.defaultInMemoryOptions())
); );
account.keys.organizationKeys.decrypted = value; account.keys.organizationKeys.decrypted = this.mapToRecord(value);
await this.saveAccount( await this.saveAccount(
account, account,
this.reconcileOptions(options, await this.defaultInMemoryOptions()) this.reconcileOptions(options, await this.defaultInMemoryOptions())
@ -725,7 +712,6 @@ export class StateService<
); );
} }
@withPrototype(EncString)
async getDecryptedPinProtected(options?: StorageOptions): Promise<EncString> { async getDecryptedPinProtected(options?: StorageOptions): Promise<EncString> {
return ( return (
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
@ -762,14 +748,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> {
@ -777,21 +758,19 @@ 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())
); );
} }
@withPrototypeForMap(SymmetricCryptoKey, SymmetricCryptoKey.fromJSON)
async getDecryptedProviderKeys( async getDecryptedProviderKeys(
options?: StorageOptions options?: StorageOptions
): Promise<Map<string, SymmetricCryptoKey>> { ): Promise<Map<string, SymmetricCryptoKey>> {
return ( const account = await this.getAccount(
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) this.reconcileOptions(options, await this.defaultInMemoryOptions())
)?.keys?.providerKeys?.decrypted; );
return this.recordToMap(account?.keys?.providerKeys?.decrypted);
} }
async setDecryptedProviderKeys( async setDecryptedProviderKeys(
@ -801,7 +780,7 @@ export class StateService<
const account = await this.getAccount( const account = await this.getAccount(
this.reconcileOptions(options, await this.defaultInMemoryOptions()) this.reconcileOptions(options, await this.defaultInMemoryOptions())
); );
account.keys.providerKeys.decrypted = value; account.keys.providerKeys.decrypted = this.mapToRecord(value);
await this.saveAccount( await this.saveAccount(
account, account,
this.reconcileOptions(options, await this.defaultInMemoryOptions()) this.reconcileOptions(options, await this.defaultInMemoryOptions())
@ -1538,7 +1517,6 @@ export class StateService<
); );
} }
@withPrototype(EnvironmentUrls)
async getEnvironmentUrls(options?: StorageOptions): Promise<EnvironmentUrls> { async getEnvironmentUrls(options?: StorageOptions): Promise<EnvironmentUrls> {
if ((await this.state())?.activeUserId == null) { if ((await this.state())?.activeUserId == null) {
return await this.getGlobalEnvironmentUrls(options); return await this.getGlobalEnvironmentUrls(options);
@ -2021,11 +1999,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> {
@ -2033,7 +2007,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())
@ -2741,8 +2714,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 state = await this.memoryStorageService.get<State<TGlobalState, TAccount>>(keys.state, {
deserializer: (s) => State.fromJSON(s),
});
return state;
} }
private async setState(state: State<TGlobalState, TAccount>): Promise<void> { private async setState(state: State<TGlobalState, TAccount>): Promise<void> {
@ -2761,6 +2737,14 @@ export class StateService<
await this.setState(updatedState); await this.setState(updatedState);
}); });
} }
private mapToRecord<V>(map: Map<string, V>): Record<string, V> {
return map == null ? null : Object.fromEntries(map);
}
private recordToMap<V>(record: Record<string, V>): Map<string, V> {
return record == null ? null : new Map(Object.entries(record));
}
} }
export function withPrototype<T>( export function withPrototype<T>(
@ -2893,52 +2877,3 @@ function withPrototypeForObjectValues<T>(
}; };
}; };
} }
function withPrototypeForMap<T>(
valuesConstructor: new (...args: any[]) => T,
valuesConverter: (input: any) => T = (i) => i
): (
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor
) => { value: (...args: any[]) => Promise<Map<string, T>> } {
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value;
return {
value: function (...args: any[]) {
const originalResult: Promise<any> = originalMethod.apply(this, args);
if (!(originalResult instanceof Promise)) {
throw new Error(
`Error applying prototype to stored value -- result is not a promise for method ${String(
propertyKey
)}`
);
}
return originalResult.then((result) => {
if (result == null) {
return null;
} else if (result instanceof Map) {
return result;
} else {
for (const key in Object.keys(result)) {
result[key] =
result[key] == null ||
result[key].constructor.name === valuesConstructor.prototype.constructor.name
? valuesConverter(result[key])
: valuesConverter(
Object.create(
valuesConstructor.prototype,
Object.getOwnPropertyDescriptors(result[key])
)
);
}
return new Map<string, T>(Object.entries(result));
}
});
},
};
};
}

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

View File

@ -93,11 +93,6 @@ export class TokenService implements TokenServiceAbstraction {
// ref https://github.com/auth0/angular-jwt/blob/master/src/angularJwt/services/jwt.js // ref https://github.com/auth0/angular-jwt/blob/master/src/angularJwt/services/jwt.js
async decodeToken(token?: string): Promise<any> { async decodeToken(token?: string): Promise<any> {
const storedToken = await this.stateService.getDecodedToken();
if (token === null && storedToken != null) {
return storedToken;
}
token = token ?? (await this.stateService.getAccessToken()); token = token ?? (await this.stateService.getAccessToken());
if (token == null) { if (token == null) {

View File

@ -0,0 +1,44 @@
import {
PositiveInfinity,
NegativeInfinity,
JsonPrimitive,
TypedArray,
JsonValue,
} from "type-fest";
import { NotJsonable } from "type-fest/source/jsonify";
/**
* Extracted from type-fest and extended with Jsonification of objects returned from `toJSON` methods.
*/
export type DeepJsonify<T> =
// Check if there are any non-JSONable types represented in the union.
// Note: The use of tuples in this first condition side-steps distributive conditional types
// (see https://github.com/microsoft/TypeScript/issues/29368#issuecomment-453529532)
[Extract<T, NotJsonable | bigint>] extends [never]
? T extends PositiveInfinity | NegativeInfinity ? null
: T extends JsonPrimitive
? T // Primitive is acceptable
: T extends number ? number
: T extends string ? string
: T extends boolean ? boolean
: T extends Map<any, any> | Set<any> ? Record<string, unknown> // {}
: T extends TypedArray ? Record<string, number>
: T extends Array<infer U>
? Array<DeepJsonify<U extends NotJsonable ? null : U>> // It's an array: recursive call for its children
: T extends object
? T extends { toJSON(): infer J }
? (() => J) extends () => JsonValue // Is J assignable to JsonValue?
? J // Then T is Jsonable and its Jsonable value is J
: {[P in keyof J as P extends symbol
? never
: J[P] extends NotJsonable
? never
: P]: DeepJsonify<Required<J>[P]>;
} // Not Jsonable because its toJSON() method does not return JsonValue
: {[P in keyof T as P extends symbol
? never
: T[P] extends NotJsonable
? never
: P]: DeepJsonify<Required<T>[P]>} // It's an object: recursive call for its children
: never // Otherwise any other non-object is removed
: never; // Otherwise non-JSONable type union was found not empty