Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-9423] observable user encryptor #10271

Merged
merged 5 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions libs/common/src/tools/state/secret-state.spec.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -36,20 +36,20 @@ const FOOBAR_RECORD = SecretKeyDefinition.record(GENERATOR_DISK, "fooBar", class

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
// while allowing the operations to be interleaved.
const encrypted = new Map<string, Jsonify<FooBar>>(
fooBar.map((fb) => [toKey(fb as any).encryptedString, toValue(fb)] as const),
);

const result = mock<UserEncryptor>({
encrypt<T>(value: Jsonify<T>, user: UserId) {
const result: UserEncryptor = mock<UserEncryptor>({
encrypt<T>(value: Jsonify<T>) {
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);
},
Expand All @@ -66,9 +66,9 @@ function mockEncryptor<T>(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() {
Expand Down
46 changes: 28 additions & 18 deletions libs/common/src/tools/state/secret-state.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -30,17 +30,18 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
// wiring the derived and secret states together.
private constructor(
private readonly key: SecretKeyDefinition<Outer, Id, Plaintext, Disclosed, Secret>,
private readonly encryptor: UserEncryptor,
private readonly $encryptor: Observable<UserEncryptor>,
userId: UserId,
provider: StateProvider,
) {
// construct the backing store
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: () => {
Expand Down Expand Up @@ -85,30 +86,33 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
userId: UserId,
key: SecretKeyDefinition<Outer, Id, TFrom, Disclosed, Secret>,
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;
}

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 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;

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
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);
Expand All @@ -117,9 +121,9 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
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
Expand All @@ -133,7 +137,7 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
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;
Expand All @@ -144,7 +148,7 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
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;
Expand All @@ -167,28 +171,34 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
configureState: (state: Outer, dependencies: TCombine) => Outer,
options: StateUpdateOptions<Outer, TCombine> = null,
): Promise<Outer> {
const combineLatestWith = combineLatest([
options?.combineLatestWith ?? of(null),
this.$encryptor,
]);

// read the backing store
let latestClassified: ClassifiedFormat<Id, Disclosed>[];
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;
}

// 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;
Expand Down
19 changes: 9 additions & 10 deletions libs/common/src/tools/state/user-encryptor.abstraction.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,33 @@
import { Jsonify } from "type-fest";

import { UserId } from "@bitwarden/common/types/guid";

import { EncString } from "../../platform/models/domain/enc-string";
import { UserId } from "../../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.
/** 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>(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
* 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>(secret: EncString, userId: UserId): Promise<Jsonify<Secret>>;
abstract decrypt<Secret>(secret: EncString): Promise<Jsonify<Secret>>;
}
92 changes: 53 additions & 39 deletions libs/common/src/tools/state/user-key-encryptor.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,7 +12,6 @@ import { UserKeyEncryptor } from "./user-key-encryptor";

describe("UserKeyEncryptor", () => {
const encryptService = mock<EncryptService>();
const keyService = mock<CryptoService>();
const dataPacker = mock<DataPacker>();
const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey;
const anyUserId = "foo" as UserId;
Expand All @@ -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(<T>(v: string) => v as T);
});
Expand All @@ -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);
describe("constructor", () => {
it("should set userId", async () => {
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",
it("should throw if userId was not supplied", async () => {
expect(() => new UserKeyEncryptor(null, encryptService, userKey, dataPacker)).toThrow(
"userId cannot be null or undefined",
);
await expect(encryptor.encrypt<Record<string, never>>(undefined, anyUserId)).rejects.toThrow(
"secret cannot be null or undefined",
expect(() => new UserKeyEncryptor(null, encryptService, userKey, dataPacker)).toThrow(
"userId cannot be null or undefined",
);
});

it("should throw if userId was not supplied", async () => {
const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
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",
);
});

await expect(encryptor.encrypt({}, null)).rejects.toThrow(
"userId 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",
);
await expect(encryptor.encrypt({}, undefined)).rejects.toThrow(
"userId 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 () => {
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);
Expand All @@ -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);
Expand Down
Loading
Loading