1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-22 16:29:09 +01:00

[PM-9423] use observable user encryptor in secret state (#10271)

This commit is contained in:
✨ Audrey ✨ 2024-08-01 17:25:10 -04:00 committed by GitHub
parent 82d6b26b18
commit d26ea1be5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 130 additions and 111 deletions

View File

@ -1,5 +1,5 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { firstValueFrom, from } from "rxjs"; import { BehaviorSubject, firstValueFrom, from, Observable } from "rxjs";
import { Jsonify } from "type-fest"; import { Jsonify } from "type-fest";
import { import {
@ -36,20 +36,20 @@ const FOOBAR_RECORD = SecretKeyDefinition.record(GENERATOR_DISK, "fooBar", class
const SomeUser = "some user" as UserId; const SomeUser = "some user" as UserId;
function mockEncryptor<T>(fooBar: T[] = []): UserEncryptor { function mockEncryptor<T>(fooBar: T[] = []): Observable<UserEncryptor> {
// stores "encrypted values" so that they can be "decrypted" later // stores "encrypted values" so that they can be "decrypted" later
// while allowing the operations to be interleaved. // while allowing the operations to be interleaved.
const encrypted = new Map<string, Jsonify<FooBar>>( const encrypted = new Map<string, Jsonify<FooBar>>(
fooBar.map((fb) => [toKey(fb as any).encryptedString, toValue(fb)] as const), fooBar.map((fb) => [toKey(fb as any).encryptedString, toValue(fb)] as const),
); );
const result = mock<UserEncryptor>({ const result: UserEncryptor = mock<UserEncryptor>({
encrypt<T>(value: Jsonify<T>, user: UserId) { encrypt<T>(value: Jsonify<T>) {
const encString = toKey(value as any); const encString = toKey(value as any);
encrypted.set(encString.encryptedString, toValue(value)); encrypted.set(encString.encryptedString, toValue(value));
return Promise.resolve(encString); return Promise.resolve(encString);
}, },
decrypt(secret: EncString, userId: UserId) { decrypt(secret: EncString) {
const decValue = encrypted.get(secret.encryptedString); const decValue = encrypted.get(secret.encryptedString);
return Promise.resolve(decValue as any); return Promise.resolve(decValue as any);
}, },
@ -66,9 +66,9 @@ function mockEncryptor<T>(fooBar: T[] = []): UserEncryptor {
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
} }
// typescript pops a false positive about missing `encrypt` and `decrypt` // wrap in a behavior subject to ensure a value is always available
// functions, so assert the type manually. const encryptor$ = new BehaviorSubject(result).asObservable();
return result as unknown as UserEncryptor; return encryptor$;
} }
async function fakeStateProvider() { async function fakeStateProvider() {

View File

@ -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 { EncString } from "../../platform/models/domain/enc-string";
import { import {
@ -30,7 +30,7 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
// wiring the derived and secret states together. // wiring the derived and secret states together.
private constructor( private constructor(
private readonly key: SecretKeyDefinition<Outer, Id, Plaintext, Disclosed, Secret>, private readonly key: SecretKeyDefinition<Outer, Id, Plaintext, Disclosed, Secret>,
private readonly encryptor: UserEncryptor, private readonly $encryptor: Observable<UserEncryptor>,
userId: UserId, userId: UserId,
provider: StateProvider, provider: StateProvider,
) { ) {
@ -38,9 +38,10 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
this.encryptedState = provider.getUser(userId, key.toEncryptedStateKey()); this.encryptedState = provider.getUser(userId, key.toEncryptedStateKey());
// cache plaintext // cache plaintext
this.combinedState$ = this.encryptedState.combinedState$.pipe( this.combinedState$ = combineLatest([this.encryptedState.combinedState$, this.$encryptor]).pipe(
concatMap( 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({ share({
connector: () => { connector: () => {
@ -85,15 +86,18 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
userId: UserId, userId: UserId,
key: SecretKeyDefinition<Outer, Id, TFrom, Disclosed, Secret>, key: SecretKeyDefinition<Outer, Id, TFrom, Disclosed, Secret>,
provider: StateProvider, provider: StateProvider,
encryptor: UserEncryptor, encryptor$: Observable<UserEncryptor>,
) { ) {
const secretState = new SecretState(key, encryptor, userId, provider); const secretState = new SecretState(key, encryptor$, userId, provider);
return secretState; return secretState;
} }
private async declassifyItem({ id, secret, disclosed }: ClassifiedFormat<Id, Disclosed>) { private async declassifyItem(
encryptor: UserEncryptor,
{ id, secret, disclosed }: ClassifiedFormat<Id, Disclosed>,
) {
const encrypted = EncString.fromJSON(secret); 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 declassified = this.key.classifier.declassify(disclosed, decrypted);
const result = [id, this.key.options.deserializer(declassified)] as const; const result = [id, this.key.options.deserializer(declassified)] as const;
@ -101,14 +105,14 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
return result; return result;
} }
private async declassifyAll(data: ClassifiedFormat<Id, Disclosed>[]) { private async declassifyAll(encryptor: UserEncryptor, data: ClassifiedFormat<Id, Disclosed>[]) {
// fail fast if there's no value // fail fast if there's no value
if (data === null || data === undefined) { if (data === null || data === undefined) {
return null; return null;
} }
// decrypt each item // 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 // reconstruct expected type
const results = await Promise.all(decryptTasks); const results = await Promise.all(decryptTasks);
@ -117,9 +121,9 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
return result; 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 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 // the deserializer in the plaintextState's `derive` configuration always runs, but
// `encryptedState` is not guaranteed to serialize the data, so it's necessary to // `encryptedState` is not guaranteed to serialize the data, so it's necessary to
@ -133,7 +137,7 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
return serialized; return serialized;
} }
private async classifyAll(data: Outer) { private async classifyAll(encryptor: UserEncryptor, data: Outer) {
// fail fast if there's no value // fail fast if there's no value
if (data === null || data === undefined) { if (data === null || data === undefined) {
return null; return null;
@ -144,7 +148,7 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
const desconstructed = this.key.deconstruct(data); const desconstructed = this.key.deconstruct(data);
// encrypt each value individually // 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); const classified = await Promise.all(classifyTasks);
return classified; return classified;
@ -167,20 +171,26 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
configureState: (state: Outer, dependencies: TCombine) => Outer, configureState: (state: Outer, dependencies: TCombine) => Outer,
options: StateUpdateOptions<Outer, TCombine> = null, options: StateUpdateOptions<Outer, TCombine> = null,
): Promise<Outer> { ): Promise<Outer> {
const combineLatestWith = combineLatest([
options?.combineLatestWith ?? of(null),
this.$encryptor,
]);
// read the backing store // read the backing store
let latestClassified: ClassifiedFormat<Id, Disclosed>[]; let latestClassified: ClassifiedFormat<Id, Disclosed>[];
let latestCombined: TCombine; let latestCombined: TCombine;
let latestEncryptor: UserEncryptor;
await this.encryptedState.update((c) => c, { await this.encryptedState.update((c) => c, {
shouldUpdate: (latest, combined) => { shouldUpdate: (latest, combined) => {
latestClassified = latest; latestClassified = latest;
latestCombined = combined; [latestCombined, latestEncryptor] = combined;
return false; return false;
}, },
combineLatestWith: options?.combineLatestWith, combineLatestWith,
}); });
// exit early if there's no update to apply // 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; const shouldUpdate = options?.shouldUpdate?.(latestDeclassified, latestCombined) ?? true;
if (!shouldUpdate) { if (!shouldUpdate) {
return latestDeclassified; return latestDeclassified;
@ -188,7 +198,7 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
// apply the update // apply the update
const updatedDeclassified = configureState(latestDeclassified, latestCombined); const updatedDeclassified = configureState(latestDeclassified, latestCombined);
const updatedClassified = await this.classifyAll(updatedDeclassified); const updatedClassified = await this.classifyAll(latestEncryptor, updatedDeclassified);
await this.encryptedState.update(() => updatedClassified); await this.encryptedState.update(() => updatedClassified);
return updatedDeclassified; return updatedDeclassified;

View File

@ -1,34 +1,33 @@
import { Jsonify } from "type-fest"; import { Jsonify } from "type-fest";
import { EncString } from "../../platform/models/domain/enc-string"; import { UserId } from "@bitwarden/common/types/guid";
import { UserId } from "../../types/guid";
/** A classification strategy that protects a type's secrets with import { EncString } from "../../platform/models/domain/enc-string";
* user-specific information. The specific kind of information is
* determined by the classification strategy. /** 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 { export abstract class UserEncryptor {
/** Identifies the user bound to the encryptor. */
readonly userId: UserId;
/** Protects secrets in `value` with a user-specific key. /** Protects secrets in `value` with a user-specific key.
* @param secret the object to protect. This object is mutated during encryption. * @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 * @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 * the encrypted secret and whose second property contains an object w/ disclosed
* properties. * properties.
* @throws If `value` is `null` or `undefined`, the promise rejects with an error. * @throws If `value` is `null` or `undefined`, the promise rejects with an error.
*/ */
abstract encrypt<Secret>(secret: Jsonify<Secret>, userId: UserId): Promise<EncString>; abstract encrypt<Secret>(secret: Jsonify<Secret>): Promise<EncString>;
/** Combines protected secrets and disclosed data into a type that can be /** Combines protected secrets and disclosed data into a type that can be
* rehydrated into a domain object. * rehydrated into a domain object.
* @param secret an encrypted JSON payload containing encrypted secrets. * @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 * @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, * class. It contains only data that can be round-tripped through JSON,
* and lacks members such as a prototype or bound functions. * and lacks members such as a prototype or bound functions.
* @throws If `secret` or `disclosed` is `null` or `undefined`, the promise * @throws If `secret` or `disclosed` is `null` or `undefined`, the promise
* rejects with an error. * rejects with an error.
*/ */
abstract decrypt<Secret>(secret: EncString, userId: UserId): Promise<Jsonify<Secret>>; abstract decrypt<Secret>(secret: EncString): Promise<Jsonify<Secret>>;
} }

View File

@ -1,6 +1,5 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { EncString } from "../../platform/models/domain/enc-string"; import { EncString } from "../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
@ -13,7 +12,6 @@ import { UserKeyEncryptor } from "./user-key-encryptor";
describe("UserKeyEncryptor", () => { describe("UserKeyEncryptor", () => {
const encryptService = mock<EncryptService>(); const encryptService = mock<EncryptService>();
const keyService = mock<CryptoService>();
const dataPacker = mock<DataPacker>(); const dataPacker = mock<DataPacker>();
const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey; const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey;
const anyUserId = "foo" as UserId; 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 // 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 // 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 // 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.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString));
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c as unknown as string)); 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.pack.mockImplementation((v) => v as string);
dataPacker.unpack.mockImplementation(<T>(v: string) => v as T); dataPacker.unpack.mockImplementation(<T>(v: string) => v as T);
}); });
@ -35,37 +32,68 @@ describe("UserKeyEncryptor", () => {
jest.resetAllMocks(); jest.resetAllMocks();
}); });
describe("encrypt", () => { describe("constructor", () => {
it("should throw if value was not supplied", async () => { it("should set userId", async () => {
const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); const encryptor = new UserKeyEncryptor(anyUserId, encryptService, userKey, dataPacker);
expect(encryptor.userId).toEqual(anyUserId);
await expect(encryptor.encrypt<Record<string, never>>(null, anyUserId)).rejects.toThrow(
"secret cannot be null or undefined",
);
await expect(encryptor.encrypt<Record<string, never>>(undefined, anyUserId)).rejects.toThrow(
"secret cannot be null or undefined",
);
}); });
it("should throw if userId was not supplied", async () => { it("should throw if userId was not supplied", async () => {
const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); expect(() => new UserKeyEncryptor(null, encryptService, userKey, dataPacker)).toThrow(
await expect(encryptor.encrypt({}, null)).rejects.toThrow(
"userId cannot be null or undefined", "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", "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<Record<string, never>>(null)).rejects.toThrow(
"secret cannot be null or undefined",
);
await expect(encryptor.encrypt<Record<string, never>>(undefined)).rejects.toThrow(
"secret cannot be null or undefined",
);
});
it("should encrypt a packed value using the user's key", async () => { 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 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 // these are data flow expectations; the operations all all pass-through mocks
expect(keyService.getUserKey).toHaveBeenCalledWith(anyUserId);
expect(dataPacker.pack).toHaveBeenCalledWith(value); expect(dataPacker.pack).toHaveBeenCalledWith(value);
expect(encryptService.encrypt).toHaveBeenCalledWith(value, userKey); expect(encryptService.encrypt).toHaveBeenCalledWith(value, userKey);
expect(result).toBe(value); expect(result).toBe(value);
@ -74,35 +102,21 @@ describe("UserKeyEncryptor", () => {
describe("decrypt", () => { describe("decrypt", () => {
it("should throw if secret was not supplied", async () => { 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", "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 () => { 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 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 // these are data flow expectations; the operations all all pass-through mocks
expect(keyService.getUserKey).toHaveBeenCalledWith(anyUserId);
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, userKey); expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, userKey);
expect(dataPacker.unpack).toHaveBeenCalledWith(secret); expect(dataPacker.unpack).toHaveBeenCalledWith(secret);
expect(result).toBe(secret); expect(result).toBe(secret);

View File

@ -1,9 +1,10 @@
import { Jsonify } from "type-fest"; 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 { EncryptService } from "../../platform/abstractions/encrypt.service";
import { EncString } from "../../platform/models/domain/enc-string"; 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 { DataPacker } from "./data-packer.abstraction";
import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserEncryptor } from "./user-encryptor.abstraction";
@ -13,45 +14,38 @@ import { UserEncryptor } from "./user-encryptor.abstraction";
*/ */
export class UserKeyEncryptor extends UserEncryptor { export class UserKeyEncryptor extends UserEncryptor {
/** Instantiates the encryptor /** Instantiates the encryptor
* @param userId identifies the user bound to the encryptor.
* @param encryptService protects properties of `Secret`. * @param encryptService protects properties of `Secret`.
* @param keyService looks up the user key when protecting data. * @param keyService looks up the user key when protecting data.
* @param dataPacker packs and unpacks data classified as secrets. * @param dataPacker packs and unpacks data classified as secrets.
*/ */
constructor( constructor(
readonly userId: UserId,
private readonly encryptService: EncryptService, private readonly encryptService: EncryptService,
private readonly keyService: CryptoService, private readonly key: UserKey,
private readonly dataPacker: DataPacker, private readonly dataPacker: DataPacker,
) { ) {
super(); super();
this.assertHasValue("userId", userId);
this.assertHasValue("key", key);
this.assertHasValue("dataPacker", dataPacker);
this.assertHasValue("encryptService", encryptService);
} }
/** {@link UserEncryptor.encrypt} */ async encrypt<Secret>(secret: Jsonify<Secret>): Promise<EncString> {
async encrypt<Secret>(secret: Jsonify<Secret>, userId: UserId): Promise<EncString> {
this.assertHasValue("secret", secret); this.assertHasValue("secret", secret);
this.assertHasValue("userId", userId);
let packed = this.dataPacker.pack(secret); let packed = this.dataPacker.pack(secret);
const encrypted = await this.encryptService.encrypt(packed, this.key);
// encrypt the data and drop the key
let key = await this.keyService.getUserKey(userId);
const encrypted = await this.encryptService.encrypt(packed, key);
packed = null; packed = null;
key = null;
return encrypted; return encrypted;
} }
/** {@link UserEncryptor.decrypt} */ async decrypt<Secret>(secret: EncString): Promise<Jsonify<Secret>> {
async decrypt<Secret>(secret: EncString, userId: UserId): Promise<Jsonify<Secret>> {
this.assertHasValue("secret", secret); this.assertHasValue("secret", secret);
this.assertHasValue("userId", userId);
// decrypt the data and drop the key let decrypted = await this.encryptService.decryptToUtf8(secret, this.key);
let key = await this.keyService.getUserKey(userId);
let decrypted = await this.encryptService.decryptToUtf8(secret, key);
key = null;
// reconstruct TFrom's data
const unpacked = this.dataPacker.unpack<Secret>(decrypted); const unpacked = this.dataPacker.unpack<Secret>(decrypted);
decrypted = null; decrypted = null;

View File

@ -37,7 +37,7 @@ describe("ForwarderGeneratorStrategy", () => {
beforeEach(() => { beforeEach(() => {
const keyAvailable = of({} as UserKey); const keyAvailable = of({} as UserKey);
keyService.getInMemoryUserKeyFor$.mockReturnValue(keyAvailable); keyService.userKey$.mockReturnValue(keyAvailable);
}); });
afterEach(() => { afterEach(() => {

View File

@ -1,4 +1,4 @@
import { map } from "rxjs"; import { filter, map } from "rxjs";
import { Jsonify } from "type-fest"; import { Jsonify } from "type-fest";
import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { PolicyType } from "@bitwarden/common/admin-console/enums";
@ -84,7 +84,10 @@ export class ForwarderGeneratorStrategy<
private getUserSecrets(userId: UserId): SingleUserState<Options> { private getUserSecrets(userId: UserId): SingleUserState<Options> {
// construct the encryptor // construct the encryptor
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); 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 // always exclude request properties
const classifier = new OptionsClassifier<Settings, Options>(); const classifier = new OptionsClassifier<Settings, Options>();
@ -106,13 +109,11 @@ export class ForwarderGeneratorStrategy<
userId, userId,
key, key,
this.stateProvider, this.stateProvider,
encryptor, encryptor$,
); );
// rollover should occur once the user key is available for decryption // rollover should occur once the user key is available for decryption
const canDecrypt$ = this.keyService const canDecrypt$ = this.keyService.userKey$(userId).pipe(map((key) => key !== null));
.getInMemoryUserKeyFor$(userId)
.pipe(map((key) => key !== null));
const rolloverState = new BufferedState( const rolloverState = new BufferedState(
this.stateProvider, this.stateProvider,
this.rolloverKey, this.rolloverKey,

View File

@ -25,7 +25,7 @@ describe("LocalGeneratorHistoryService", () => {
encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString)); encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString));
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString)); encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString));
keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey)); 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(() => { afterEach(() => {

View File

@ -1,4 +1,4 @@
import { map } from "rxjs"; import { filter, map } from "rxjs";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
@ -110,7 +110,10 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService {
private createSecretState(userId: UserId): SingleUserState<GeneratedCredential[]> { private createSecretState(userId: UserId): SingleUserState<GeneratedCredential[]> {
// construct the encryptor // construct the encryptor
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); 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 // construct the durable state
const state = SecretState.from< const state = SecretState.from<
@ -119,7 +122,7 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService {
GeneratedCredential, GeneratedCredential,
Record<keyof GeneratedCredential, never>, Record<keyof GeneratedCredential, never>,
GeneratedCredential 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; // 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 // providing it via an observable makes running it early impossible
@ -128,9 +131,7 @@ export class LocalGeneratorHistoryService extends GeneratorHistoryService {
this.keyService, this.keyService,
this.encryptService, this.encryptService,
); );
const decryptor$ = this.keyService const decryptor$ = this.keyService.userKey$(userId).pipe(map((key) => key && decryptor));
.getInMemoryUserKeyFor$(userId)
.pipe(map((key) => key && decryptor));
// move data from the old password history once decryptor is available // move data from the old password history once decryptor is available
const buffer = new BufferedState( const buffer = new BufferedState(