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:
parent
82d6b26b18
commit
d26ea1be5f
@ -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() {
|
||||||
|
@ -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;
|
||||||
|
@ -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>>;
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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,
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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(
|
||||||
|
Loading…
Reference in New Issue
Block a user