Browse Source
* Use abstract methods and generics in StorageService * Prepend `Abstract` to abstract classes * Create session browser storage service * Use memory storage service for state memory * Inject memory storage service * Maintain filename extensions to help ide formatting * Preserve state if it's still in memory * Use jslib's memory storage service * linter * Create prototypes on stored objects * standardize package scripts * Add type safety to `withPrototype` decorators * webpack notify manifest version * Fix desktop * linter * Fix script * Improve prototye application * do not change prototype if it already matches desired * fix error with object values prototype application * Handle null state * Apply prototypes to browser-specific state * Add angular language server to recommended extensions * Improve browser state service tests * Start testing state Service * Fix abstract returns * Move test setup files to not be picked up by default glob matchers * Add key generation service * Add low-dependency encrypt service * Back crypto service with encrypt service. We'll want to work items that don't require state over to encrypt service * Add new storage service and tests * Properly init more stored values * Fix reload issues when state service is recovering state from session storage Co-authored-by: Thomas Avery <Thomas-Avery@users.noreply.github.com> Co-authored-by: Justin Baur <admin@justinbaur.com> * Simplify encrypt service * Do not log mac failures for local-backed session storage * `content` changed to `main` in #2245 * Fix CLI * Remove loggin * PR feedback * Merge remote-tracking branch 'origin/master' into add-memory-storage-to-state-service * Fix desktop * Fix decrypt method signature * Minify if not development * Key is required Co-authored-by: Thomas Avery <Thomas-Avery@users.noreply.github.com> Co-authored-by: Justin Baur <admin@justinbaur.com>pull/2988/head
57 changed files with 1575 additions and 370 deletions
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; |
||||
|
||||
export interface AbstractKeyGenerationService { |
||||
makeEphemeralKey(numBytes?: number): Promise<SymmetricCryptoKey>; |
||||
} |
||||
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
import AbstractChromeStorageService from "./abstractChromeStorageApi.service"; |
||||
|
||||
export default class BrowserLocalStorageService extends AbstractChromeStorageService { |
||||
protected chromeStorageApi: any = chrome.storage.local; |
||||
} |
||||
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
import AbstractChromeStorageService from "./abstractChromeStorageApi.service"; |
||||
|
||||
export default class BrowserMemoryStorageService extends AbstractChromeStorageService { |
||||
protected chromeStorageApi: any = (chrome.storage as any).session; |
||||
} |
||||
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; |
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; |
||||
|
||||
import { AbstractKeyGenerationService } from "./abstractions/abstractKeyGeneration.service"; |
||||
|
||||
export class KeyGenerationService implements AbstractKeyGenerationService { |
||||
constructor(private cryptoFunctionService: CryptoFunctionService) {} |
||||
|
||||
async makeEphemeralKey(numBytes = 16): Promise<SymmetricCryptoKey> { |
||||
const keyMaterial = await this.cryptoFunctionService.randomBytes(numBytes); |
||||
const key = await this.cryptoFunctionService.hkdf( |
||||
keyMaterial, |
||||
"bitwarden-ephemeral", |
||||
"ephemeral", |
||||
64, |
||||
"sha256" |
||||
); |
||||
return new SymmetricCryptoKey(key); |
||||
} |
||||
} |
||||
@ -0,0 +1,308 @@
@@ -0,0 +1,308 @@
|
||||
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; |
||||
|
||||
import { Utils } from "@bitwarden/common/misc/utils"; |
||||
import { EncString } from "@bitwarden/common/models/domain/encString"; |
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; |
||||
import { EncryptService } from "@bitwarden/common/src/services/encrypt.service"; |
||||
|
||||
import BrowserLocalStorageService from "./browserLocalStorage.service"; |
||||
import BrowserMemoryStorageService from "./browserMemoryStorage.service"; |
||||
import { KeyGenerationService } from "./keyGeneration.service"; |
||||
import { LocalBackedSessionStorageService } from "./localBackedSessionStorage.service"; |
||||
|
||||
describe("Browser Session Storage Service", () => { |
||||
let encryptService: SubstituteOf<EncryptService>; |
||||
let keyGenerationService: SubstituteOf<KeyGenerationService>; |
||||
|
||||
let cache: Map<string, any>; |
||||
const testObj = { a: 1, b: 2 }; |
||||
|
||||
let localStorage: BrowserLocalStorageService; |
||||
let sessionStorage: BrowserMemoryStorageService; |
||||
|
||||
const key = new SymmetricCryptoKey( |
||||
Utils.fromUtf8ToArray("00000000000000000000000000000000").buffer |
||||
); |
||||
let getSessionKeySpy: jest.SpyInstance; |
||||
const mockEnc = (input: string) => Promise.resolve(new EncString("ENCRYPTED" + input)); |
||||
|
||||
let sut: LocalBackedSessionStorageService; |
||||
|
||||
beforeEach(() => { |
||||
encryptService = Substitute.for(); |
||||
keyGenerationService = Substitute.for(); |
||||
|
||||
sut = new LocalBackedSessionStorageService(encryptService, keyGenerationService); |
||||
|
||||
cache = sut["cache"]; |
||||
localStorage = sut["localStorage"]; |
||||
sessionStorage = sut["sessionStorage"]; |
||||
getSessionKeySpy = jest.spyOn(sut, "getSessionEncKey"); |
||||
getSessionKeySpy.mockResolvedValue(key); |
||||
}); |
||||
|
||||
it("should exist", () => { |
||||
expect(sut).toBeInstanceOf(LocalBackedSessionStorageService); |
||||
}); |
||||
|
||||
describe("get", () => { |
||||
it("should return from cache", async () => { |
||||
cache.set("test", testObj); |
||||
const result = await sut.get("test"); |
||||
expect(result).toStrictEqual(testObj); |
||||
}); |
||||
|
||||
describe("not in cache", () => { |
||||
const session = { test: testObj }; |
||||
|
||||
beforeEach(() => { |
||||
jest.spyOn(sut, "getSessionEncKey").mockResolvedValue(key); |
||||
}); |
||||
|
||||
describe("no session retrieved", () => { |
||||
let result: any; |
||||
let spy: jest.SpyInstance; |
||||
beforeEach(async () => { |
||||
spy = jest.spyOn(sut, "getLocalSession").mockResolvedValue(null); |
||||
result = await sut.get("test"); |
||||
}); |
||||
|
||||
it("should grab from session if not in cache", async () => { |
||||
expect(spy).toHaveBeenCalledWith(key); |
||||
}); |
||||
|
||||
it("should return null if session is null", async () => { |
||||
expect(result).toBeNull(); |
||||
}); |
||||
}); |
||||
|
||||
describe("session retrieved from storage", () => { |
||||
beforeEach(() => { |
||||
jest.spyOn(sut, "getLocalSession").mockResolvedValue(session); |
||||
}); |
||||
|
||||
it("should return null if session does not have the key", async () => { |
||||
const result = await sut.get("DNE"); |
||||
expect(result).toBeNull(); |
||||
}); |
||||
|
||||
it("should return the value retrieved from session", async () => { |
||||
const result = await sut.get("test"); |
||||
expect(result).toEqual(session.test); |
||||
}); |
||||
|
||||
it("should set retrieved values in cache", async () => { |
||||
await sut.get("test"); |
||||
expect(cache.has("test")).toBe(true); |
||||
expect(cache.get("test")).toEqual(session.test); |
||||
}); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe("has", () => { |
||||
it("should be false if `get` returns null", async () => { |
||||
const spy = jest.spyOn(sut, "get"); |
||||
spy.mockResolvedValue(null); |
||||
expect(await sut.has("test")).toBe(false); |
||||
expect(spy).toHaveBeenCalledWith("test"); |
||||
}); |
||||
|
||||
it("should be true if `get` returns non-null", async () => { |
||||
const spy = jest.spyOn(sut, "get"); |
||||
spy.mockResolvedValue({}); |
||||
expect(await sut.has("test")).toBe(true); |
||||
expect(spy).toHaveBeenCalledWith("test"); |
||||
}); |
||||
}); |
||||
|
||||
describe("remove", () => { |
||||
it("should save null", async () => { |
||||
const spy = jest.spyOn(sut, "save"); |
||||
spy.mockResolvedValue(null); |
||||
await sut.remove("test"); |
||||
expect(spy).toHaveBeenCalledWith("test", null); |
||||
}); |
||||
}); |
||||
|
||||
describe("save", () => { |
||||
describe("caching", () => { |
||||
beforeEach(() => { |
||||
jest.spyOn(localStorage, "get").mockResolvedValue(null); |
||||
jest.spyOn(sessionStorage, "get").mockResolvedValue(null); |
||||
jest.spyOn(localStorage, "save").mockResolvedValue(); |
||||
jest.spyOn(sessionStorage, "save").mockResolvedValue(); |
||||
}); |
||||
|
||||
it("should remove key from cache if value is null", async () => { |
||||
cache.set("test", {}); |
||||
const deleteSpy = jest.spyOn(cache, "delete"); |
||||
expect(cache.has("test")).toBe(true); |
||||
await sut.save("test", null); |
||||
expect(cache.has("test")).toBe(false); |
||||
expect(deleteSpy).toHaveBeenCalledWith("test"); |
||||
}); |
||||
|
||||
it("should set cache if value is non-null", async () => { |
||||
expect(cache.has("test")).toBe(false); |
||||
const setSpy = jest.spyOn(cache, "set"); |
||||
await sut.save("test", testObj); |
||||
expect(cache.get("test")).toBe(testObj); |
||||
expect(setSpy).toHaveBeenCalledWith("test", testObj); |
||||
}); |
||||
}); |
||||
|
||||
describe("local storing", () => { |
||||
let setSpy: jest.SpyInstance; |
||||
|
||||
beforeEach(() => { |
||||
setSpy = jest.spyOn(sut, "setLocalSession").mockResolvedValue(); |
||||
}); |
||||
|
||||
it("should store a new session", async () => { |
||||
jest.spyOn(sut, "getLocalSession").mockResolvedValue(null); |
||||
await sut.save("test", testObj); |
||||
|
||||
expect(setSpy).toHaveBeenCalledWith({ test: testObj }, key); |
||||
}); |
||||
|
||||
it("should update an existing session", async () => { |
||||
const existingObj = { test: testObj }; |
||||
jest.spyOn(sut, "getLocalSession").mockResolvedValue(existingObj); |
||||
await sut.save("test2", testObj); |
||||
|
||||
expect(setSpy).toHaveBeenCalledWith({ test2: testObj, ...existingObj }, key); |
||||
}); |
||||
|
||||
it("should overwrite an existing item in session", async () => { |
||||
const existingObj = { test: {} }; |
||||
jest.spyOn(sut, "getLocalSession").mockResolvedValue(existingObj); |
||||
await sut.save("test", testObj); |
||||
|
||||
expect(setSpy).toHaveBeenCalledWith({ test: testObj }, key); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe("getSessionKey", () => { |
||||
beforeEach(() => { |
||||
getSessionKeySpy.mockRestore(); |
||||
}); |
||||
|
||||
it("should return the stored symmetric crypto key", async () => { |
||||
jest.spyOn(sessionStorage, "get").mockResolvedValue({ ...key }); |
||||
const result = await sut.getSessionEncKey(); |
||||
|
||||
expect(result).toStrictEqual(key); |
||||
}); |
||||
|
||||
describe("new key creation", () => { |
||||
beforeEach(() => { |
||||
jest.spyOn(sessionStorage, "get").mockResolvedValue(null); |
||||
keyGenerationService.makeEphemeralKey().resolves(key); |
||||
jest.spyOn(sut, "setSessionEncKey").mockResolvedValue(); |
||||
}); |
||||
|
||||
it("should create a symmetric crypto key", async () => { |
||||
const result = await sut.getSessionEncKey(); |
||||
|
||||
expect(result).toStrictEqual(key); |
||||
keyGenerationService.received(1).makeEphemeralKey(); |
||||
}); |
||||
|
||||
it("should store a symmetric crypto key if it makes one", async () => { |
||||
const spy = jest.spyOn(sut, "setSessionEncKey").mockResolvedValue(); |
||||
await sut.getSessionEncKey(); |
||||
|
||||
expect(spy).toBeCalledWith(key); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe("getLocalSession", () => { |
||||
it("should return null if session is null", async () => { |
||||
const spy = jest.spyOn(localStorage, "get").mockResolvedValue(null); |
||||
const result = await sut.getLocalSession(key); |
||||
|
||||
expect(result).toBeNull(); |
||||
expect(spy).toBeCalledWith("session"); |
||||
}); |
||||
|
||||
describe("non-null sessions", () => { |
||||
const session = { test: "test" }; |
||||
const encSession = new EncString(JSON.stringify(session)); |
||||
const decryptedSession = JSON.stringify(session); |
||||
|
||||
beforeEach(() => { |
||||
jest.spyOn(localStorage, "get").mockResolvedValue(encSession.encryptedString); |
||||
}); |
||||
|
||||
it("should decrypt returned sessions", async () => { |
||||
encryptService.decryptToUtf8(encSession, key).resolves(decryptedSession); |
||||
await sut.getLocalSession(key); |
||||
encryptService.received(1).decryptToUtf8(encSession, key); |
||||
}); |
||||
|
||||
it("should parse session", async () => { |
||||
encryptService.decryptToUtf8(encSession, key).resolves(decryptedSession); |
||||
const result = await sut.getLocalSession(key); |
||||
expect(result).toEqual(session); |
||||
}); |
||||
|
||||
it("should remove state if decryption fails", async () => { |
||||
encryptService.decryptToUtf8(Arg.any(), Arg.any()).resolves(null); |
||||
const setSessionEncKeySpy = jest.spyOn(sut, "setSessionEncKey").mockResolvedValue(); |
||||
const removeLocalSessionSpy = jest.spyOn(localStorage, "remove").mockResolvedValue(); |
||||
|
||||
const result = await sut.getLocalSession(key); |
||||
|
||||
expect(result).toBeNull(); |
||||
expect(setSessionEncKeySpy).toHaveBeenCalledWith(null); |
||||
expect(removeLocalSessionSpy).toHaveBeenCalledWith("session"); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe("setLocalSession", () => { |
||||
const testSession = { test: "a" }; |
||||
const testJSON = JSON.stringify(testSession); |
||||
|
||||
it("should encrypt a stringified session", async () => { |
||||
encryptService.encrypt(Arg.any(), Arg.any()).mimicks(mockEnc); |
||||
jest.spyOn(localStorage, "save").mockResolvedValue(); |
||||
await sut.setLocalSession(testSession, key); |
||||
|
||||
encryptService.received(1).encrypt(testJSON, key); |
||||
}); |
||||
|
||||
it("should remove local session if null", async () => { |
||||
encryptService.encrypt(Arg.any(), Arg.any()).resolves(null); |
||||
const spy = jest.spyOn(localStorage, "remove").mockResolvedValue(); |
||||
await sut.setLocalSession(null, key); |
||||
|
||||
expect(spy).toHaveBeenCalledWith("session"); |
||||
}); |
||||
|
||||
it("should save encrypted string", async () => { |
||||
encryptService.encrypt(Arg.any(), Arg.any()).mimicks(mockEnc); |
||||
const spy = jest.spyOn(localStorage, "save").mockResolvedValue(); |
||||
await sut.setLocalSession(testSession, key); |
||||
|
||||
expect(spy).toHaveBeenCalledWith("session", (await mockEnc(testJSON)).encryptedString); |
||||
}); |
||||
}); |
||||
|
||||
describe("setSessionKey", () => { |
||||
it("should remove if null", async () => { |
||||
const spy = jest.spyOn(sessionStorage, "remove").mockResolvedValue(); |
||||
await sut.setSessionEncKey(null); |
||||
expect(spy).toHaveBeenCalledWith("localEncryptionKey"); |
||||
}); |
||||
|
||||
it("should save key when not null", async () => { |
||||
const spy = jest.spyOn(sessionStorage, "save").mockResolvedValue(); |
||||
await sut.setSessionEncKey(key); |
||||
expect(spy).toHaveBeenCalledWith("localEncryptionKey", key); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,107 @@
@@ -0,0 +1,107 @@
|
||||
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service"; |
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; |
||||
import { EncString } from "@bitwarden/common/models/domain/encString"; |
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; |
||||
|
||||
import { AbstractKeyGenerationService } from "./abstractions/abstractKeyGeneration.service"; |
||||
import BrowserLocalStorageService from "./browserLocalStorage.service"; |
||||
import BrowserMemoryStorageService from "./browserMemoryStorage.service"; |
||||
|
||||
const keys = { |
||||
encKey: "localEncryptionKey", |
||||
sessionKey: "session", |
||||
}; |
||||
|
||||
export class LocalBackedSessionStorageService extends AbstractStorageService { |
||||
private cache = new Map<string, any>(); |
||||
private localStorage = new BrowserLocalStorageService(); |
||||
private sessionStorage = new BrowserMemoryStorageService(); |
||||
|
||||
constructor( |
||||
private encryptService: AbstractEncryptService, |
||||
private keyGenerationService: AbstractKeyGenerationService |
||||
) { |
||||
super(); |
||||
} |
||||
|
||||
async get<T>(key: string): Promise<T> { |
||||
if (this.cache.has(key)) { |
||||
return this.cache.get(key); |
||||
} |
||||
|
||||
const session = await this.getLocalSession(await this.getSessionEncKey()); |
||||
if (session == null || !Object.keys(session).includes(key)) { |
||||
return null; |
||||
} |
||||
|
||||
this.cache.set(key, session[key]); |
||||
return this.cache.get(key); |
||||
} |
||||
|
||||
async has(key: string): Promise<boolean> { |
||||
return (await this.get(key)) != null; |
||||
} |
||||
|
||||
async save(key: string, obj: any): Promise<void> { |
||||
if (obj == null) { |
||||
this.cache.delete(key); |
||||
} else { |
||||
this.cache.set(key, obj); |
||||
} |
||||
|
||||
const sessionEncKey = await this.getSessionEncKey(); |
||||
const localSession = (await this.getLocalSession(sessionEncKey)) ?? {}; |
||||
localSession[key] = obj; |
||||
await this.setLocalSession(localSession, sessionEncKey); |
||||
} |
||||
|
||||
async remove(key: string): Promise<void> { |
||||
await this.save(key, null); |
||||
} |
||||
|
||||
async getLocalSession(encKey: SymmetricCryptoKey): Promise<any> { |
||||
const local = await this.localStorage.get<string>(keys.sessionKey); |
||||
|
||||
if (local == null) { |
||||
return null; |
||||
} |
||||
|
||||
const sessionJson = await this.encryptService.decryptToUtf8(new EncString(local), encKey); |
||||
if (sessionJson == null) { |
||||
// Error with decryption -- session is lost, delete state and key and start over
|
||||
await this.setSessionEncKey(null); |
||||
await this.localStorage.remove(keys.sessionKey); |
||||
return null; |
||||
} |
||||
return JSON.parse(sessionJson); |
||||
} |
||||
|
||||
async setLocalSession(session: any, key: SymmetricCryptoKey) { |
||||
const jsonSession = JSON.stringify(session); |
||||
const encSession = await this.encryptService.encrypt(jsonSession, key); |
||||
|
||||
if (encSession == null) { |
||||
return await this.localStorage.remove(keys.sessionKey); |
||||
} |
||||
await this.localStorage.save(keys.sessionKey, encSession.encryptedString); |
||||
} |
||||
|
||||
async getSessionEncKey(): Promise<SymmetricCryptoKey> { |
||||
let storedKey = (await this.sessionStorage.get(keys.encKey)) as SymmetricCryptoKey; |
||||
if (storedKey == null || Object.keys(storedKey).length == 0) { |
||||
storedKey = await this.keyGenerationService.makeEphemeralKey(); |
||||
await this.setSessionEncKey(storedKey); |
||||
} |
||||
return SymmetricCryptoKey.initFromJson( |
||||
Object.create(SymmetricCryptoKey.prototype, Object.getOwnPropertyDescriptors(storedKey)) |
||||
); |
||||
} |
||||
|
||||
async setSessionEncKey(input: SymmetricCryptoKey): Promise<void> { |
||||
if (input == null) { |
||||
await this.sessionStorage.remove(keys.encKey); |
||||
} else { |
||||
await this.sessionStorage.save(keys.encKey, input); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
|
||||
import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; |
||||
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service"; |
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; |
||||
import { SendType } from "@bitwarden/common/enums/sendType"; |
||||
import { StateFactory } from "@bitwarden/common/factories/stateFactory"; |
||||
import { GlobalState } from "@bitwarden/common/models/domain/globalState"; |
||||
import { State } from "@bitwarden/common/models/domain/state"; |
||||
import { SendView } from "@bitwarden/common/models/view/sendView"; |
||||
import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service"; |
||||
|
||||
import { Account } from "../models/account"; |
||||
import { BrowserComponentState } from "../models/browserComponentState"; |
||||
import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState"; |
||||
import { BrowserSendComponentState } from "../models/browserSendComponentState"; |
||||
|
||||
import { StateService } from "./state.service"; |
||||
|
||||
describe("Browser State Service", () => { |
||||
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()); |
||||
state.accounts[userId] = new Account({ |
||||
profile: { userId: userId }, |
||||
}); |
||||
state.activeUserId = userId; |
||||
const stateGetter = (key: string) => Promise.resolve(JSON.parse(JSON.stringify(state))); |
||||
memoryStorageService.get("state").mimicks(stateGetter); |
||||
|
||||
sut = new StateService( |
||||
diskStorageService, |
||||
secureStorageService, |
||||
memoryStorageService, |
||||
logService, |
||||
stateMigrationService, |
||||
stateFactory, |
||||
useAccountCache |
||||
); |
||||
}); |
||||
|
||||
describe("getBrowserGroupingComponentState", () => { |
||||
it("should return a BrowserGroupingsComponentState", async () => { |
||||
state.accounts[userId].groupings = new BrowserGroupingsComponentState(); |
||||
|
||||
const actual = await sut.getBrowserGroupingComponentState(); |
||||
expect(actual).toBeInstanceOf(BrowserGroupingsComponentState); |
||||
}); |
||||
}); |
||||
|
||||
describe("getBrowserCipherComponentState", () => { |
||||
it("should return a BrowserComponentState", async () => { |
||||
const componentState = new BrowserComponentState(); |
||||
componentState.scrollY = 0; |
||||
componentState.searchText = "test"; |
||||
state.accounts[userId].ciphers = componentState; |
||||
|
||||
const actual = await sut.getBrowserCipherComponentState(); |
||||
expect(actual).toStrictEqual(componentState); |
||||
}); |
||||
}); |
||||
|
||||
describe("getBrowserSendComponentState", () => { |
||||
it("should return a BrowserSendComponentState", async () => { |
||||
const sendState = new BrowserSendComponentState(); |
||||
sendState.sends = [new SendView(), new SendView()]; |
||||
sendState.typeCounts = new Map<SendType, number>([ |
||||
[SendType.File, 3], |
||||
[SendType.Text, 5], |
||||
]); |
||||
state.accounts[userId].send = sendState; |
||||
|
||||
const actual = await sut.getBrowserSendComponentState(); |
||||
expect(actual).toBeInstanceOf(BrowserSendComponentState); |
||||
expect(actual).toMatchObject(sendState); |
||||
}); |
||||
}); |
||||
|
||||
describe("getBrowserSendTypeComponentState", () => { |
||||
it("should return a BrowserComponentState", async () => { |
||||
const componentState = new BrowserComponentState(); |
||||
componentState.scrollY = 0; |
||||
componentState.searchText = "test"; |
||||
state.accounts[userId].sendType = componentState; |
||||
|
||||
const actual = await sut.getBrowserSendTypeComponentState(); |
||||
expect(actual).toStrictEqual(componentState); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
// Add chrome storage api
|
||||
const get = jest.fn(); |
||||
const set = jest.fn(); |
||||
const has = jest.fn(); |
||||
const remove = jest.fn(); |
||||
const QUOTA_BYTES = 10; |
||||
const getBytesInUse = jest.fn(); |
||||
const clear = jest.fn(); |
||||
global.chrome = { |
||||
storage: { |
||||
local: { |
||||
set, |
||||
get, |
||||
remove, |
||||
QUOTA_BYTES, |
||||
getBytesInUse, |
||||
clear, |
||||
}, |
||||
session: { |
||||
set, |
||||
get, |
||||
has, |
||||
remove, |
||||
}, |
||||
}, |
||||
} as any; |
||||
@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
|
||||
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 }, |
||||
}); |
||||
} |
||||
}); |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
import { EncString } from "@bitwarden/common/models/domain/encString"; |
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; |
||||
|
||||
export abstract class AbstractEncryptService { |
||||
abstract encrypt(plainValue: string | ArrayBuffer, key: SymmetricCryptoKey): Promise<EncString>; |
||||
abstract decryptToUtf8(encString: EncString, key: SymmetricCryptoKey): Promise<string>; |
||||
} |
||||
@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
|
||||
import { StorageOptions } from "../models/domain/storageOptions"; |
||||
|
||||
export abstract class StorageService { |
||||
get: <T>(key: string, options?: StorageOptions) => Promise<T>; |
||||
has: (key: string, options?: StorageOptions) => Promise<boolean>; |
||||
save: (key: string, obj: any, options?: StorageOptions) => Promise<any>; |
||||
remove: (key: string, options?: StorageOptions) => Promise<any>; |
||||
export abstract class AbstractStorageService { |
||||
abstract get<T>(key: string, options?: StorageOptions): Promise<T>; |
||||
abstract has(key: string, options?: StorageOptions): Promise<boolean>; |
||||
abstract save<T>(key: string, obj: T, options?: StorageOptions): Promise<void>; |
||||
abstract remove(key: string, options?: StorageOptions): Promise<void>; |
||||
} |
||||
|
||||
@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
|
||||
import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; |
||||
import { LogService } from "@bitwarden/common/abstractions/log.service"; |
||||
import { Utils } from "@bitwarden/common/misc/utils"; |
||||
import { EncString } from "@bitwarden/common/models/domain/encString"; |
||||
import { EncryptedObject } from "@bitwarden/common/models/domain/encryptedObject"; |
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; |
||||
|
||||
import { AbstractEncryptService } from "../abstractions/abstractEncrypt.service"; |
||||
|
||||
export class EncryptService implements AbstractEncryptService { |
||||
constructor( |
||||
private cryptoFunctionService: CryptoFunctionService, |
||||
private logService: LogService, |
||||
private logMacFailures: boolean |
||||
) {} |
||||
|
||||
async encrypt(plainValue: string | ArrayBuffer, key: SymmetricCryptoKey): Promise<EncString> { |
||||
if (key == null) { |
||||
throw new Error("no encryption key provided."); |
||||
} |
||||
|
||||
if (plainValue == null) { |
||||
return Promise.resolve(null); |
||||
} |
||||
|
||||
let plainBuf: ArrayBuffer; |
||||
if (typeof plainValue === "string") { |
||||
plainBuf = Utils.fromUtf8ToArray(plainValue).buffer; |
||||
} else { |
||||
plainBuf = plainValue; |
||||
} |
||||
|
||||
const encObj = await this.aesEncrypt(plainBuf, key); |
||||
const iv = Utils.fromBufferToB64(encObj.iv); |
||||
const data = Utils.fromBufferToB64(encObj.data); |
||||
const mac = encObj.mac != null ? Utils.fromBufferToB64(encObj.mac) : null; |
||||
return new EncString(encObj.key.encType, data, iv, mac); |
||||
} |
||||
|
||||
async decryptToUtf8(encString: EncString, key: SymmetricCryptoKey): Promise<string> { |
||||
if (key?.macKey != null && encString?.mac == null) { |
||||
this.logService.error("mac required."); |
||||
return null; |
||||
} |
||||
|
||||
if (key.encType !== encString.encryptionType) { |
||||
this.logService.error("encType unavailable."); |
||||
return null; |
||||
} |
||||
|
||||
const fastParams = this.cryptoFunctionService.aesDecryptFastParameters( |
||||
encString.data, |
||||
encString.iv, |
||||
encString.mac, |
||||
key |
||||
); |
||||
if (fastParams.macKey != null && fastParams.mac != null) { |
||||
const computedMac = await this.cryptoFunctionService.hmacFast( |
||||
fastParams.macData, |
||||
fastParams.macKey, |
||||
"sha256" |
||||
); |
||||
const macsEqual = await this.cryptoFunctionService.compareFast(fastParams.mac, computedMac); |
||||
if (!macsEqual) { |
||||
this.logMacFailed("mac failed."); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
return this.cryptoFunctionService.aesDecryptFast(fastParams); |
||||
} |
||||
|
||||
private async aesEncrypt(data: ArrayBuffer, key: SymmetricCryptoKey): Promise<EncryptedObject> { |
||||
const obj = new EncryptedObject(); |
||||
obj.key = key; |
||||
obj.iv = await this.cryptoFunctionService.randomBytes(16); |
||||
obj.data = await this.cryptoFunctionService.aesEncrypt(data, obj.iv, obj.key.encKey); |
||||
|
||||
if (obj.key.macKey != null) { |
||||
const macData = new Uint8Array(obj.iv.byteLength + obj.data.byteLength); |
||||
macData.set(new Uint8Array(obj.iv), 0); |
||||
macData.set(new Uint8Array(obj.data), obj.iv.byteLength); |
||||
obj.mac = await this.cryptoFunctionService.hmac(macData.buffer, obj.key.macKey, "sha256"); |
||||
} |
||||
|
||||
return obj; |
||||
} |
||||
|
||||
private logMacFailed(msg: string) { |
||||
if (this.logMacFailures) { |
||||
this.logService.error(msg); |
||||
} |
||||
} |
||||
} |
||||
@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
|
||||
import { StorageService } from "@bitwarden/common/abstractions/storage.service"; |
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; |
||||
|
||||
export class MemoryStorageService implements StorageService { |
||||
export class MemoryStorageService implements AbstractStorageService { |
||||
private store = new Map<string, any>(); |
||||
|
||||
get<T>(key: string): Promise<T> { |
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue