Skip to content

Commit

Permalink
[PM-15061] extract encryptors from generator service (#12068)
Browse files Browse the repository at this point in the history
* introduce legacy encryptor provider
* port credential generation service to encryptor provider
  • Loading branch information
audreyality authored Nov 28, 2024
1 parent 927c2fc commit ab21b78
Show file tree
Hide file tree
Showing 33 changed files with 1,378 additions and 293 deletions.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import {
connect,
dematerialize,
map,
materialize,
ReplaySubject,
skipWhile,
switchMap,
takeUntil,
takeWhile,
} from "rxjs";

import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { KeyService } from "@bitwarden/key-management";

import {
OrganizationBound,
SingleOrganizationDependency,
SingleUserDependency,
UserBound,
} from "../dependencies";
import { anyComplete, errorOnChange } from "../rx";
import { PaddedDataPacker } from "../state/padded-data-packer";

import { LegacyEncryptorProvider } from "./legacy-encryptor-provider";
import { OrganizationEncryptor } from "./organization-encryptor.abstraction";
import { OrganizationKeyEncryptor } from "./organization-key-encryptor";
import { UserEncryptor } from "./user-encryptor.abstraction";
import { UserKeyEncryptor } from "./user-key-encryptor";

/** Creates encryptors
*/
export class KeyServiceLegacyEncryptorProvider implements LegacyEncryptorProvider {
/** Instantiates the legacy encryptor provider.
* @param encryptService injected into encryptors to perform encryption
* @param keyService looks up keys for construction into an encryptor
*/
constructor(
private readonly encryptService: EncryptService,
private readonly keyService: KeyService,
) {}

userEncryptor$(frameSize: number, dependencies: SingleUserDependency) {
const packer = new PaddedDataPacker(frameSize);
const encryptor$ = dependencies.singleUserId$.pipe(
errorOnChange(
(userId) => userId,
(expectedUserId, actualUserId) => ({ expectedUserId, actualUserId }),
),
connect((singleUserId$) => {
const singleUserId = new ReplaySubject<UserId>(1);
singleUserId$.subscribe(singleUserId);

return singleUserId.pipe(
switchMap((userId) =>
this.keyService.userKey$(userId).pipe(
// wait until the key becomes available
skipWhile((key) => !key),
// complete when the key becomes unavailable
takeWhile((key) => !!key),
map((key) => {
const encryptor = new UserKeyEncryptor(userId, this.encryptService, key, packer);

return { userId, encryptor } satisfies UserBound<"encryptor", UserEncryptor>;
}),
materialize(),
),
),
dematerialize(),
takeUntil(anyComplete(singleUserId)),
);
}),
);

return encryptor$;
}

organizationEncryptor$(frameSize: number, dependencies: SingleOrganizationDependency) {
const packer = new PaddedDataPacker(frameSize);
const encryptor$ = dependencies.singleOrganizationId$.pipe(
errorOnChange(
(pair) => pair.userId,
(expectedUserId, actualUserId) => ({ expectedUserId, actualUserId }),
),
errorOnChange(
(pair) => pair.organizationId,
(expectedOrganizationId, actualOrganizationId) => ({
expectedOrganizationId,
actualOrganizationId,
}),
),
connect((singleOrganizationId$) => {
const singleOrganizationId = new ReplaySubject<UserBound<"organizationId", OrganizationId>>(
1,
);
singleOrganizationId$.subscribe(singleOrganizationId);

return singleOrganizationId.pipe(
switchMap((pair) =>
this.keyService.orgKeys$(pair.userId).pipe(
// wait until the key becomes available
skipWhile((keys) => !keys),
// complete when the key becomes unavailable
takeWhile((keys) => !!keys),
map((keys) => {
const organizationId = pair.organizationId;
const key = keys[organizationId];
const encryptor = new OrganizationKeyEncryptor(
organizationId,
this.encryptService,
key,
packer,
);

return { organizationId, encryptor } satisfies OrganizationBound<
"encryptor",
OrganizationEncryptor
>;
}),
materialize(),
),
),
dematerialize(),
takeUntil(anyComplete(singleOrganizationId)),
);
}),
);

return encryptor$;
}
}
42 changes: 42 additions & 0 deletions libs/common/src/tools/cryptography/legacy-encryptor-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Observable } from "rxjs";

import {
OrganizationBound,
SingleOrganizationDependency,
SingleUserDependency,
UserBound,
} from "../dependencies";

import { OrganizationEncryptor } from "./organization-encryptor.abstraction";
import { UserEncryptor } from "./user-encryptor.abstraction";

/** Creates encryptors
* @deprecated this logic will soon be replaced with a design that provides for
* key rotation. Use it at your own risk
*/
export abstract class LegacyEncryptorProvider {
/** Retrieves an encryptor populated with the user's most recent key instance that
* uses a padded data packer to encode data.
* @param frameSize length of the padded data packer's frames.
* @param dependencies.singleUserId$ identifies the user to which the encryptor is bound
* @returns an observable that emits when the key becomes available and completes
* when the key becomes unavailable.
*/
userEncryptor$: (
frameSize: number,
dependencies: SingleUserDependency,
) => Observable<UserBound<"encryptor", UserEncryptor>>;

/** Retrieves an encryptor populated with the organization's most recent key instance that
* uses a padded data packer to encode data.
* @param frameSize length of the padded data packer's frames.
* @param dependencies.singleOrganizationId$ identifies the user/org combination
* to which the encryptor is bound.
* @returns an observable that emits when the key becomes available and completes
* when the key becomes unavailable.
*/
organizationEncryptor$: (
frameSize: number,
dependences: SingleOrganizationDependency,
) => Observable<OrganizationBound<"encryptor", OrganizationEncryptor>>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Jsonify } from "type-fest";

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

import { EncString } from "../../platform/models/domain/enc-string";

/** An encryption strategy that protects a type's secrets with
* organization-specific keys. This strategy is bound to a specific organization.
*/
export abstract class OrganizationEncryptor {
/** Identifies the organization bound to the encryptor. */
readonly organizationId: OrganizationId;

/** Protects secrets in `value` with an organization-specific key.
* @param secret the object to protect. This object is mutated during encryption.
* @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>): 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.
* @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): Promise<Jsonify<Secret>>;
}
125 changes: 125 additions & 0 deletions libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { mock } from "jest-mock-extended";

import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { EncString } from "../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "../../types/csprng";
import { OrganizationId } from "../../types/guid";
import { OrgKey } from "../../types/key";
import { DataPacker } from "../state/data-packer.abstraction";

import { OrganizationKeyEncryptor } from "./organization-key-encryptor";

describe("OrgKeyEncryptor", () => {
const encryptService = mock<EncryptService>();
const dataPacker = mock<DataPacker>();
const orgKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as OrgKey;
const anyOrgId = "foo" as OrganizationId;

beforeEach(() => {
// The OrgKeyEncryptor is, in large part, a facade coordinating a handful of worker
// 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, 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));
dataPacker.pack.mockImplementation((v) => v as string);
dataPacker.unpack.mockImplementation(<T>(v: string) => v as T);
});

afterEach(() => {
jest.resetAllMocks();
});

describe("constructor", () => {
it("should set organizationId", async () => {
const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker);
expect(encryptor.organizationId).toEqual(anyOrgId);
});

it("should throw if organizationId was not supplied", async () => {
expect(() => new OrganizationKeyEncryptor(null, encryptService, orgKey, dataPacker)).toThrow(
"organizationId cannot be null or undefined",
);
expect(() => new OrganizationKeyEncryptor(null, encryptService, orgKey, dataPacker)).toThrow(
"organizationId cannot be null or undefined",
);
});

it("should throw if encryptService was not supplied", async () => {
expect(() => new OrganizationKeyEncryptor(anyOrgId, null, orgKey, dataPacker)).toThrow(
"encryptService cannot be null or undefined",
);
expect(() => new OrganizationKeyEncryptor(anyOrgId, null, orgKey, dataPacker)).toThrow(
"encryptService cannot be null or undefined",
);
});

it("should throw if key was not supplied", async () => {
expect(
() => new OrganizationKeyEncryptor(anyOrgId, encryptService, null, dataPacker),
).toThrow("key cannot be null or undefined");
expect(
() => new OrganizationKeyEncryptor(anyOrgId, encryptService, null, dataPacker),
).toThrow("key cannot be null or undefined");
});

it("should throw if dataPacker was not supplied", async () => {
expect(() => new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, null)).toThrow(
"dataPacker cannot be null or undefined",
);
expect(() => new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, null)).toThrow(
"dataPacker cannot be null or undefined",
);
});
});

describe("encrypt", () => {
it("should throw if value was not supplied", async () => {
const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, 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 organization's key", async () => {
const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker);
const value = { foo: true };

const result = await encryptor.encrypt(value);

// these are data flow expectations; the operations all all pass-through mocks
expect(dataPacker.pack).toHaveBeenCalledWith(value);
expect(encryptService.encrypt).toHaveBeenCalledWith(value, orgKey);
expect(result).toBe(value);
});
});

describe("decrypt", () => {
it("should throw if secret was not supplied", async () => {
const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker);

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",
);
});

it("should declassify a decrypted packed value using the organization's key", async () => {
const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker);
const secret = "encrypted" as any;

const result = await encryptor.decrypt(secret);

// these are data flow expectations; the operations all all pass-through mocks
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, orgKey);
expect(dataPacker.unpack).toHaveBeenCalledWith(secret);
expect(result).toBe(secret);
});
});
});
60 changes: 60 additions & 0 deletions libs/common/src/tools/cryptography/organization-key-encryptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Jsonify } from "type-fest";

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

import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { EncString } from "../../platform/models/domain/enc-string";
import { OrgKey } from "../../types/key";
import { DataPacker } from "../state/data-packer.abstraction";

import { OrganizationEncryptor } from "./organization-encryptor.abstraction";

/** A classification strategy that protects a type's secrets by encrypting them
* with an `OrgKey`
*/
export class OrganizationKeyEncryptor extends OrganizationEncryptor {
/** Instantiates the encryptor
* @param organizationId identifies the organization bound to the encryptor.
* @param encryptService protects properties of `Secret`.
* @param key the key instance protecting the data.
* @param dataPacker packs and unpacks data classified as secrets.
*/
constructor(
readonly organizationId: OrganizationId,
private readonly encryptService: EncryptService,
private readonly key: OrgKey,
private readonly dataPacker: DataPacker,
) {
super();
this.assertHasValue("organizationId", organizationId);
this.assertHasValue("key", key);
this.assertHasValue("dataPacker", dataPacker);
this.assertHasValue("encryptService", encryptService);
}

async encrypt<Secret>(secret: Jsonify<Secret>): Promise<EncString> {
this.assertHasValue("secret", secret);

let packed = this.dataPacker.pack(secret);
const encrypted = await this.encryptService.encrypt(packed, this.key);
packed = null;

return encrypted;
}

async decrypt<Secret>(secret: EncString): Promise<Jsonify<Secret>> {
this.assertHasValue("secret", secret);

let decrypted = await this.encryptService.decryptToUtf8(secret, this.key);
const unpacked = this.dataPacker.unpack<Secret>(decrypted);
decrypted = null;

return unpacked;
}

private assertHasValue(name: string, value: any) {
if (value === undefined || value === null) {
throw new Error(`${name} cannot be null or undefined`);
}
}
}
Loading

0 comments on commit ab21b78

Please sign in to comment.