From d26ea1be5f4404c0e9e44bf2aeb51c04c468c426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Thu, 1 Aug 2024 17:25:10 -0400 Subject: [PATCH] [PM-9423] use observable user encryptor in secret state (#10271) --- .../src/tools/state/secret-state.spec.ts | 16 ++-- libs/common/src/tools/state/secret-state.ts | 46 +++++---- .../tools/state/user-encryptor.abstraction.ts | 21 ++--- .../tools/state/user-key-encryptor.spec.ts | 94 +++++++++++-------- .../src/tools/state/user-key-encryptor.ts | 34 +++---- .../forwarder-generator-strategy.spec.ts | 2 +- .../forwarder-generator-strategy.ts | 13 +-- .../local-generator-history.service.spec.ts | 2 +- .../src/local-generator-history.service.ts | 13 +-- 9 files changed, 130 insertions(+), 111 deletions(-) diff --git a/libs/common/src/tools/state/secret-state.spec.ts b/libs/common/src/tools/state/secret-state.spec.ts index a3d22c14bf..d4727492b3 100644 --- a/libs/common/src/tools/state/secret-state.spec.ts +++ b/libs/common/src/tools/state/secret-state.spec.ts @@ -1,5 +1,5 @@ import { mock } from "jest-mock-extended"; -import { firstValueFrom, from } from "rxjs"; +import { BehaviorSubject, firstValueFrom, from, Observable } from "rxjs"; import { Jsonify } from "type-fest"; import { @@ -36,20 +36,20 @@ const FOOBAR_RECORD = SecretKeyDefinition.record(GENERATOR_DISK, "fooBar", class const SomeUser = "some user" as UserId; -function mockEncryptor(fooBar: T[] = []): UserEncryptor { +function mockEncryptor(fooBar: T[] = []): Observable { // stores "encrypted values" so that they can be "decrypted" later // while allowing the operations to be interleaved. const encrypted = new Map>( fooBar.map((fb) => [toKey(fb as any).encryptedString, toValue(fb)] as const), ); - const result = mock({ - encrypt(value: Jsonify, user: UserId) { + const result: UserEncryptor = mock({ + encrypt(value: Jsonify) { const encString = toKey(value as any); encrypted.set(encString.encryptedString, toValue(value)); return Promise.resolve(encString); }, - decrypt(secret: EncString, userId: UserId) { + decrypt(secret: EncString) { const decValue = encrypted.get(secret.encryptedString); return Promise.resolve(decValue as any); }, @@ -66,9 +66,9 @@ function mockEncryptor(fooBar: T[] = []): UserEncryptor { return JSON.parse(JSON.stringify(value)); } - // typescript pops a false positive about missing `encrypt` and `decrypt` - // functions, so assert the type manually. - return result as unknown as UserEncryptor; + // wrap in a behavior subject to ensure a value is always available + const encryptor$ = new BehaviorSubject(result).asObservable(); + return encryptor$; } async function fakeStateProvider() { diff --git a/libs/common/src/tools/state/secret-state.ts b/libs/common/src/tools/state/secret-state.ts index 6713b56ea1..45ce855cc8 100644 --- a/libs/common/src/tools/state/secret-state.ts +++ b/libs/common/src/tools/state/secret-state.ts @@ -1,4 +1,4 @@ -import { Observable, map, concatMap, share, ReplaySubject, timer } from "rxjs"; +import { Observable, map, concatMap, share, ReplaySubject, timer, combineLatest, of } from "rxjs"; import { EncString } from "../../platform/models/domain/enc-string"; import { @@ -30,7 +30,7 @@ export class SecretState // wiring the derived and secret states together. private constructor( private readonly key: SecretKeyDefinition, - private readonly encryptor: UserEncryptor, + private readonly $encryptor: Observable, userId: UserId, provider: StateProvider, ) { @@ -38,9 +38,10 @@ export class SecretState this.encryptedState = provider.getUser(userId, key.toEncryptedStateKey()); // cache plaintext - this.combinedState$ = this.encryptedState.combinedState$.pipe( + this.combinedState$ = combineLatest([this.encryptedState.combinedState$, this.$encryptor]).pipe( concatMap( - async ([userId, state]) => [userId, await this.declassifyAll(state)] as [UserId, Outer], + async ([[userId, state], encryptor]) => + [userId, await this.declassifyAll(encryptor, state)] as [UserId, Outer], ), share({ connector: () => { @@ -85,15 +86,18 @@ export class SecretState userId: UserId, key: SecretKeyDefinition, provider: StateProvider, - encryptor: UserEncryptor, + encryptor$: Observable, ) { - const secretState = new SecretState(key, encryptor, userId, provider); + const secretState = new SecretState(key, encryptor$, userId, provider); return secretState; } - private async declassifyItem({ id, secret, disclosed }: ClassifiedFormat) { + private async declassifyItem( + encryptor: UserEncryptor, + { id, secret, disclosed }: ClassifiedFormat, + ) { const encrypted = EncString.fromJSON(secret); - const decrypted = await this.encryptor.decrypt(encrypted, this.encryptedState.userId); + const decrypted = await encryptor.decrypt(encrypted); const declassified = this.key.classifier.declassify(disclosed, decrypted); const result = [id, this.key.options.deserializer(declassified)] as const; @@ -101,14 +105,14 @@ export class SecretState return result; } - private async declassifyAll(data: ClassifiedFormat[]) { + private async declassifyAll(encryptor: UserEncryptor, data: ClassifiedFormat[]) { // fail fast if there's no value if (data === null || data === undefined) { return null; } // decrypt each item - const decryptTasks = data.map(async (item) => this.declassifyItem(item)); + const decryptTasks = data.map(async (item) => this.declassifyItem(encryptor, item)); // reconstruct expected type const results = await Promise.all(decryptTasks); @@ -117,9 +121,9 @@ export class SecretState return result; } - private async classifyItem([id, item]: [Id, Plaintext]) { + private async classifyItem(encryptor: UserEncryptor, [id, item]: [Id, Plaintext]) { const classified = this.key.classifier.classify(item); - const encrypted = await this.encryptor.encrypt(classified.secret, this.encryptedState.userId); + const encrypted = await encryptor.encrypt(classified.secret); // the deserializer in the plaintextState's `derive` configuration always runs, but // `encryptedState` is not guaranteed to serialize the data, so it's necessary to @@ -133,7 +137,7 @@ export class SecretState return serialized; } - private async classifyAll(data: Outer) { + private async classifyAll(encryptor: UserEncryptor, data: Outer) { // fail fast if there's no value if (data === null || data === undefined) { return null; @@ -144,7 +148,7 @@ export class SecretState const desconstructed = this.key.deconstruct(data); // encrypt each value individually - const classifyTasks = desconstructed.map(async (item) => this.classifyItem(item)); + const classifyTasks = desconstructed.map(async (item) => this.classifyItem(encryptor, item)); const classified = await Promise.all(classifyTasks); return classified; @@ -167,20 +171,26 @@ export class SecretState configureState: (state: Outer, dependencies: TCombine) => Outer, options: StateUpdateOptions = null, ): Promise { + const combineLatestWith = combineLatest([ + options?.combineLatestWith ?? of(null), + this.$encryptor, + ]); + // read the backing store let latestClassified: ClassifiedFormat[]; let latestCombined: TCombine; + let latestEncryptor: UserEncryptor; await this.encryptedState.update((c) => c, { shouldUpdate: (latest, combined) => { latestClassified = latest; - latestCombined = combined; + [latestCombined, latestEncryptor] = combined; return false; }, - combineLatestWith: options?.combineLatestWith, + combineLatestWith, }); // exit early if there's no update to apply - const latestDeclassified = await this.declassifyAll(latestClassified); + const latestDeclassified = await this.declassifyAll(latestEncryptor, latestClassified); const shouldUpdate = options?.shouldUpdate?.(latestDeclassified, latestCombined) ?? true; if (!shouldUpdate) { return latestDeclassified; @@ -188,7 +198,7 @@ export class SecretState // apply the update const updatedDeclassified = configureState(latestDeclassified, latestCombined); - const updatedClassified = await this.classifyAll(updatedDeclassified); + const updatedClassified = await this.classifyAll(latestEncryptor, updatedDeclassified); await this.encryptedState.update(() => updatedClassified); return updatedDeclassified; diff --git a/libs/common/src/tools/state/user-encryptor.abstraction.ts b/libs/common/src/tools/state/user-encryptor.abstraction.ts index 7638ac1658..70b2fa82b8 100644 --- a/libs/common/src/tools/state/user-encryptor.abstraction.ts +++ b/libs/common/src/tools/state/user-encryptor.abstraction.ts @@ -1,34 +1,33 @@ import { Jsonify } from "type-fest"; -import { EncString } from "../../platform/models/domain/enc-string"; -import { UserId } from "../../types/guid"; +import { UserId } from "@bitwarden/common/types/guid"; -/** A classification strategy that protects a type's secrets with - * user-specific information. The specific kind of information is - * determined by the classification strategy. +import { EncString } from "../../platform/models/domain/enc-string"; + +/** An encryption strategy that protects a type's secrets with + * user-specific keys. This strategy is bound to a specific user. */ export abstract class UserEncryptor { + /** Identifies the user bound to the encryptor. */ + readonly userId: UserId; + /** Protects secrets in `value` with a user-specific key. * @param secret the object to protect. This object is mutated during encryption. - * @param userId identifies the user-specific information used to protect - * the secret. * @returns a promise that resolves to a tuple. The tuple's first property contains * the encrypted secret and whose second property contains an object w/ disclosed * properties. * @throws If `value` is `null` or `undefined`, the promise rejects with an error. */ - abstract encrypt(secret: Jsonify, userId: UserId): Promise; + abstract encrypt(secret: Jsonify): Promise; /** Combines protected secrets and disclosed data into a type that can be * rehydrated into a domain object. * @param secret an encrypted JSON payload containing encrypted secrets. - * @param userId identifies the user-specific information used to protect - * the secret. * @returns a promise that resolves to the raw state. This state *is not* a * class. It contains only data that can be round-tripped through JSON, * and lacks members such as a prototype or bound functions. * @throws If `secret` or `disclosed` is `null` or `undefined`, the promise * rejects with an error. */ - abstract decrypt(secret: EncString, userId: UserId): Promise>; + abstract decrypt(secret: EncString): Promise>; } diff --git a/libs/common/src/tools/state/user-key-encryptor.spec.ts b/libs/common/src/tools/state/user-key-encryptor.spec.ts index fac9fc1fca..37c1155488 100644 --- a/libs/common/src/tools/state/user-key-encryptor.spec.ts +++ b/libs/common/src/tools/state/user-key-encryptor.spec.ts @@ -1,6 +1,5 @@ import { mock } from "jest-mock-extended"; -import { CryptoService } from "../../platform/abstractions/crypto.service"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { EncString } from "../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; @@ -13,7 +12,6 @@ import { UserKeyEncryptor } from "./user-key-encryptor"; describe("UserKeyEncryptor", () => { const encryptService = mock(); - const keyService = mock(); const dataPacker = mock(); const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey; const anyUserId = "foo" as UserId; @@ -23,10 +21,9 @@ describe("UserKeyEncryptor", () => { // objects, so its tests focus on how data flows between components. The defaults rely // on this property--that the facade treats its data like a opaque objects--to trace // the data through several function calls. Should the encryptor interact with the - // objects themselves, it will break. + // objects themselves, these mocks will break. encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString)); encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c as unknown as string)); - keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey)); dataPacker.pack.mockImplementation((v) => v as string); dataPacker.unpack.mockImplementation((v: string) => v as T); }); @@ -35,37 +32,68 @@ describe("UserKeyEncryptor", () => { jest.resetAllMocks(); }); - describe("encrypt", () => { - it("should throw if value was not supplied", async () => { - const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); - - await expect(encryptor.encrypt>(null, anyUserId)).rejects.toThrow( - "secret cannot be null or undefined", - ); - await expect(encryptor.encrypt>(undefined, anyUserId)).rejects.toThrow( - "secret cannot be null or undefined", - ); + describe("constructor", () => { + it("should set userId", async () => { + const encryptor = new UserKeyEncryptor(anyUserId, encryptService, userKey, dataPacker); + expect(encryptor.userId).toEqual(anyUserId); }); it("should throw if userId was not supplied", async () => { - const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); - - await expect(encryptor.encrypt({}, null)).rejects.toThrow( + expect(() => new UserKeyEncryptor(null, encryptService, userKey, dataPacker)).toThrow( "userId cannot be null or undefined", ); - await expect(encryptor.encrypt({}, undefined)).rejects.toThrow( + expect(() => new UserKeyEncryptor(null, encryptService, userKey, dataPacker)).toThrow( "userId cannot be null or undefined", ); }); + it("should throw if encryptService was not supplied", async () => { + expect(() => new UserKeyEncryptor(anyUserId, null, userKey, dataPacker)).toThrow( + "encryptService cannot be null or undefined", + ); + expect(() => new UserKeyEncryptor(anyUserId, null, userKey, dataPacker)).toThrow( + "encryptService cannot be null or undefined", + ); + }); + + it("should throw if key was not supplied", async () => { + expect(() => new UserKeyEncryptor(anyUserId, encryptService, null, dataPacker)).toThrow( + "key cannot be null or undefined", + ); + expect(() => new UserKeyEncryptor(anyUserId, encryptService, null, dataPacker)).toThrow( + "key cannot be null or undefined", + ); + }); + + it("should throw if dataPacker was not supplied", async () => { + expect(() => new UserKeyEncryptor(anyUserId, encryptService, userKey, null)).toThrow( + "dataPacker cannot be null or undefined", + ); + expect(() => new UserKeyEncryptor(anyUserId, encryptService, userKey, null)).toThrow( + "dataPacker cannot be null or undefined", + ); + }); + }); + + describe("encrypt", () => { + it("should throw if value was not supplied", async () => { + const encryptor = new UserKeyEncryptor(anyUserId, encryptService, userKey, dataPacker); + + await expect(encryptor.encrypt>(null)).rejects.toThrow( + "secret cannot be null or undefined", + ); + await expect(encryptor.encrypt>(undefined)).rejects.toThrow( + "secret cannot be null or undefined", + ); + }); + it("should encrypt a packed value using the user's key", async () => { - const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); + const encryptor = new UserKeyEncryptor(anyUserId, encryptService, userKey, dataPacker); const value = { foo: true }; - const result = await encryptor.encrypt(value, anyUserId); + const result = await encryptor.encrypt(value); // these are data flow expectations; the operations all all pass-through mocks - expect(keyService.getUserKey).toHaveBeenCalledWith(anyUserId); expect(dataPacker.pack).toHaveBeenCalledWith(value); expect(encryptService.encrypt).toHaveBeenCalledWith(value, userKey); expect(result).toBe(value); @@ -74,35 +102,21 @@ describe("UserKeyEncryptor", () => { describe("decrypt", () => { it("should throw if secret was not supplied", async () => { - const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); + const encryptor = new UserKeyEncryptor(anyUserId, encryptService, userKey, dataPacker); - await expect(encryptor.decrypt(null, anyUserId)).rejects.toThrow( + await expect(encryptor.decrypt(null)).rejects.toThrow("secret cannot be null or undefined"); + await expect(encryptor.decrypt(undefined)).rejects.toThrow( "secret cannot be null or undefined", ); - await expect(encryptor.decrypt(undefined, anyUserId)).rejects.toThrow( - "secret cannot be null or undefined", - ); - }); - - it("should throw if userId was not supplied", async () => { - const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); - - await expect(encryptor.decrypt({} as any, null)).rejects.toThrow( - "userId cannot be null or undefined", - ); - await expect(encryptor.decrypt({} as any, undefined)).rejects.toThrow( - "userId cannot be null or undefined", - ); }); it("should declassify a decrypted packed value using the user's key", async () => { - const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); + const encryptor = new UserKeyEncryptor(anyUserId, encryptService, userKey, dataPacker); const secret = "encrypted" as any; - const result = await encryptor.decrypt(secret, anyUserId); + const result = await encryptor.decrypt(secret); // these are data flow expectations; the operations all all pass-through mocks - expect(keyService.getUserKey).toHaveBeenCalledWith(anyUserId); expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, userKey); expect(dataPacker.unpack).toHaveBeenCalledWith(secret); expect(result).toBe(secret); diff --git a/libs/common/src/tools/state/user-key-encryptor.ts b/libs/common/src/tools/state/user-key-encryptor.ts index ef4ac5aeb7..d0316636d2 100644 --- a/libs/common/src/tools/state/user-key-encryptor.ts +++ b/libs/common/src/tools/state/user-key-encryptor.ts @@ -1,9 +1,10 @@ import { Jsonify } from "type-fest"; -import { CryptoService } from "../../platform/abstractions/crypto.service"; +import { UserId } from "@bitwarden/common/types/guid"; + import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { EncString } from "../../platform/models/domain/enc-string"; -import { UserId } from "../../types/guid"; +import { UserKey } from "../../types/key"; import { DataPacker } from "./data-packer.abstraction"; import { UserEncryptor } from "./user-encryptor.abstraction"; @@ -13,45 +14,38 @@ import { UserEncryptor } from "./user-encryptor.abstraction"; */ export class UserKeyEncryptor extends UserEncryptor { /** Instantiates the encryptor + * @param userId identifies the user bound to the encryptor. * @param encryptService protects properties of `Secret`. * @param keyService looks up the user key when protecting data. * @param dataPacker packs and unpacks data classified as secrets. */ constructor( + readonly userId: UserId, private readonly encryptService: EncryptService, - private readonly keyService: CryptoService, + private readonly key: UserKey, private readonly dataPacker: DataPacker, ) { super(); + this.assertHasValue("userId", userId); + this.assertHasValue("key", key); + this.assertHasValue("dataPacker", dataPacker); + this.assertHasValue("encryptService", encryptService); } - /** {@link UserEncryptor.encrypt} */ - async encrypt(secret: Jsonify, userId: UserId): Promise { + async encrypt(secret: Jsonify): Promise { this.assertHasValue("secret", secret); - this.assertHasValue("userId", userId); let packed = this.dataPacker.pack(secret); - - // encrypt the data and drop the key - let key = await this.keyService.getUserKey(userId); - const encrypted = await this.encryptService.encrypt(packed, key); + const encrypted = await this.encryptService.encrypt(packed, this.key); packed = null; - key = null; return encrypted; } - /** {@link UserEncryptor.decrypt} */ - async decrypt(secret: EncString, userId: UserId): Promise> { + async decrypt(secret: EncString): Promise> { this.assertHasValue("secret", secret); - this.assertHasValue("userId", userId); - // decrypt the data and drop the key - let key = await this.keyService.getUserKey(userId); - let decrypted = await this.encryptService.decryptToUtf8(secret, key); - key = null; - - // reconstruct TFrom's data + let decrypted = await this.encryptService.decryptToUtf8(secret, this.key); const unpacked = this.dataPacker.unpack(decrypted); decrypted = null; diff --git a/libs/tools/generator/core/src/strategies/forwarder-generator-strategy.spec.ts b/libs/tools/generator/core/src/strategies/forwarder-generator-strategy.spec.ts index 51a5032a8f..09f3ccd87a 100644 --- a/libs/tools/generator/core/src/strategies/forwarder-generator-strategy.spec.ts +++ b/libs/tools/generator/core/src/strategies/forwarder-generator-strategy.spec.ts @@ -37,7 +37,7 @@ describe("ForwarderGeneratorStrategy", () => { beforeEach(() => { const keyAvailable = of({} as UserKey); - keyService.getInMemoryUserKeyFor$.mockReturnValue(keyAvailable); + keyService.userKey$.mockReturnValue(keyAvailable); }); afterEach(() => { diff --git a/libs/tools/generator/core/src/strategies/forwarder-generator-strategy.ts b/libs/tools/generator/core/src/strategies/forwarder-generator-strategy.ts index b4fb4aabc6..04989cce19 100644 --- a/libs/tools/generator/core/src/strategies/forwarder-generator-strategy.ts +++ b/libs/tools/generator/core/src/strategies/forwarder-generator-strategy.ts @@ -1,4 +1,4 @@ -import { map } from "rxjs"; +import { filter, map } from "rxjs"; import { Jsonify } from "type-fest"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -84,7 +84,10 @@ export class ForwarderGeneratorStrategy< private getUserSecrets(userId: UserId): SingleUserState { // construct the encryptor const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); - const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); + const encryptor$ = this.keyService.userKey$(userId).pipe( + map((key) => (key ? new UserKeyEncryptor(userId, this.encryptService, key, packer) : null)), + filter((encryptor) => !!encryptor), + ); // always exclude request properties const classifier = new OptionsClassifier(); @@ -106,13 +109,11 @@ export class ForwarderGeneratorStrategy< userId, key, this.stateProvider, - encryptor, + encryptor$, ); // rollover should occur once the user key is available for decryption - const canDecrypt$ = this.keyService - .getInMemoryUserKeyFor$(userId) - .pipe(map((key) => key !== null)); + const canDecrypt$ = this.keyService.userKey$(userId).pipe(map((key) => key !== null)); const rolloverState = new BufferedState( this.stateProvider, this.rolloverKey, diff --git a/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts b/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts index 6ac336960b..1fbc956bc5 100644 --- a/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts +++ b/libs/tools/generator/extensions/history/src/local-generator-history.service.spec.ts @@ -25,7 +25,7 @@ describe("LocalGeneratorHistoryService", () => { encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString)); encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString)); keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey)); - keyService.getInMemoryUserKeyFor$.mockImplementation(() => of(true as unknown as UserKey)); + keyService.userKey$.mockImplementation(() => of(true as unknown as UserKey)); }); afterEach(() => { diff --git a/libs/tools/generator/extensions/history/src/local-generator-history.service.ts b/libs/tools/generator/extensions/history/src/local-generator-history.service.ts index 138a6afa4d..2416c84b63 100644 --- a/libs/tools/generator/extensions/history/src/local-generator-history.service.ts +++ b/libs/tools/generator/extensions/history/src/local-generator-history.service.ts @@ -1,4 +1,4 @@ -import { map } from "rxjs"; +import { filter, map } from "rxjs"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; @@ -110,7 +110,10 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { private createSecretState(userId: UserId): SingleUserState { // construct the encryptor const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); - const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); + const encryptor$ = this.keyService.userKey$(userId).pipe( + map((key) => (key ? new UserKeyEncryptor(userId, this.encryptService, key, packer) : null)), + filter((encryptor) => !!encryptor), + ); // construct the durable state const state = SecretState.from< @@ -119,7 +122,7 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { GeneratedCredential, Record, GeneratedCredential - >(userId, GENERATOR_HISTORY, this.stateProvider, encryptor); + >(userId, GENERATOR_HISTORY, this.stateProvider, encryptor$); // decryptor is just an algorithm, but it can't run until the key is available; // providing it via an observable makes running it early impossible @@ -128,9 +131,7 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService { this.keyService, this.encryptService, ); - const decryptor$ = this.keyService - .getInMemoryUserKeyFor$(userId) - .pipe(map((key) => key && decryptor)); + const decryptor$ = this.keyService.userKey$(userId).pipe(map((key) => key && decryptor)); // move data from the old password history once decryptor is available const buffer = new BufferedState(