mirror of
https://github.com/bitwarden/browser
synced 2025-12-19 01:33:33 +00:00
[PM-15061] extract encryptors from generator service (#12068)
* introduce legacy encryptor provider * port credential generation service to encryptor provider
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, Subject } from "rxjs";
|
||||
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { OrganizationBound, UserBound } from "../dependencies";
|
||||
|
||||
import { KeyServiceLegacyEncryptorProvider } from "./key-service-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";
|
||||
|
||||
const encryptService = mock<EncryptService>();
|
||||
const keyService = mock<KeyService>();
|
||||
|
||||
const SomeCsprngArray = new Uint8Array(64) as CsprngArray;
|
||||
const SomeUser = "some user" as UserId;
|
||||
const AnotherUser = "another user" as UserId;
|
||||
const SomeUserKey = new SymmetricCryptoKey(SomeCsprngArray) as UserKey;
|
||||
const SomeOrganization = "some organization" as OrganizationId;
|
||||
const AnotherOrganization = "another organization" as OrganizationId;
|
||||
const SomeOrgKey = new SymmetricCryptoKey(SomeCsprngArray) as OrgKey;
|
||||
const AnotherOrgKey = new SymmetricCryptoKey(SomeCsprngArray) as OrgKey;
|
||||
const OrgRecords: Record<OrganizationId, OrgKey> = {
|
||||
[SomeOrganization]: SomeOrgKey,
|
||||
[AnotherOrganization]: AnotherOrgKey,
|
||||
};
|
||||
|
||||
// Many tests examine the private members of the objects constructed by the
|
||||
// provider. This is necessary because it's not presently possible to spy
|
||||
// on the constructors directly.
|
||||
describe("KeyServiceLegacyEncryptorProvider", () => {
|
||||
describe("userEncryptor$", () => {
|
||||
it("emits a user key encryptor bound to the user", async () => {
|
||||
const userKey$ = new BehaviorSubject<UserKey>(SomeUserKey);
|
||||
keyService.userKey$.mockReturnValue(userKey$);
|
||||
const singleUserId$ = new BehaviorSubject<UserId>(SomeUser);
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
const results: UserBound<"encryptor", UserEncryptor>[] = [];
|
||||
|
||||
provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v));
|
||||
|
||||
expect(keyService.userKey$).toHaveBeenCalledWith(SomeUser);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]).toMatchObject({
|
||||
userId: SomeUser,
|
||||
encryptor: {
|
||||
userId: SomeUser,
|
||||
key: SomeUserKey,
|
||||
dataPacker: { frameSize: 1 },
|
||||
},
|
||||
});
|
||||
expect(results[0].encryptor).toBeInstanceOf(UserKeyEncryptor);
|
||||
});
|
||||
|
||||
it("waits until `dependencies.singleUserId$` emits", () => {
|
||||
const userKey$ = new BehaviorSubject<UserKey>(SomeUserKey);
|
||||
keyService.userKey$.mockReturnValue(userKey$);
|
||||
const singleUserId$ = new Subject<UserId>();
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
const results: UserBound<"encryptor", UserEncryptor>[] = [];
|
||||
provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v));
|
||||
// precondition: no emissions occur on subscribe
|
||||
expect(results.length).toBe(0);
|
||||
|
||||
singleUserId$.next(SomeUser);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
});
|
||||
|
||||
it("emits a new user key encryptor each time `dependencies.singleUserId$` emits", () => {
|
||||
const userKey$ = new BehaviorSubject<UserKey>(SomeUserKey);
|
||||
keyService.userKey$.mockReturnValue(userKey$);
|
||||
const singleUserId$ = new Subject<UserId>();
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
const results: UserBound<"encryptor", UserEncryptor>[] = [];
|
||||
provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v));
|
||||
|
||||
singleUserId$.next(SomeUser);
|
||||
singleUserId$.next(SomeUser);
|
||||
|
||||
expect(results.length).toBe(2);
|
||||
expect(results[0]).not.toBe(results[1]);
|
||||
});
|
||||
|
||||
it("waits until `userKey$` emits a truthy value", () => {
|
||||
const userKey$ = new BehaviorSubject<UserKey>(null);
|
||||
keyService.userKey$.mockReturnValue(userKey$);
|
||||
const singleUserId$ = new BehaviorSubject<UserId>(SomeUser);
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
const results: UserBound<"encryptor", UserEncryptor>[] = [];
|
||||
provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v));
|
||||
// precondition: no emissions occur on subscribe
|
||||
expect(results.length).toBe(0);
|
||||
|
||||
userKey$.next(SomeUserKey);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]).toMatchObject({
|
||||
userId: SomeUser,
|
||||
encryptor: {
|
||||
userId: SomeUser,
|
||||
key: SomeUserKey,
|
||||
dataPacker: { frameSize: 1 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a user key encryptor each time `userKey$` emits", () => {
|
||||
const userKey$ = new Subject<UserKey>();
|
||||
keyService.userKey$.mockReturnValue(userKey$);
|
||||
const singleUserId$ = new BehaviorSubject<UserId>(SomeUser);
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
const results: UserBound<"encryptor", UserEncryptor>[] = [];
|
||||
provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v));
|
||||
|
||||
userKey$.next(SomeUserKey);
|
||||
userKey$.next(SomeUserKey);
|
||||
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
it("errors when the userId changes", () => {
|
||||
const userKey$ = new BehaviorSubject<UserKey>(SomeUserKey);
|
||||
keyService.userKey$.mockReturnValue(userKey$);
|
||||
const singleUserId$ = new Subject<UserId>();
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
let error: unknown = false;
|
||||
provider
|
||||
.userEncryptor$(1, { singleUserId$ })
|
||||
.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
singleUserId$.next(SomeUser);
|
||||
singleUserId$.next(AnotherUser);
|
||||
|
||||
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: AnotherUser });
|
||||
});
|
||||
|
||||
it("errors when `dependencies.singleUserId$` errors", () => {
|
||||
const userKey$ = new BehaviorSubject<UserKey>(SomeUserKey);
|
||||
keyService.userKey$.mockReturnValue(userKey$);
|
||||
const singleUserId$ = new Subject<UserId>();
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
let error: unknown = false;
|
||||
provider
|
||||
.userEncryptor$(1, { singleUserId$ })
|
||||
.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
singleUserId$.error({ some: "error" });
|
||||
|
||||
expect(error).toEqual({ some: "error" });
|
||||
});
|
||||
|
||||
it("errors once `dependencies.singleUserId$` emits and `userKey$` errors", () => {
|
||||
const userKey$ = new Subject<UserKey>();
|
||||
keyService.userKey$.mockReturnValue(userKey$);
|
||||
const singleUserId$ = new BehaviorSubject<UserId>(SomeUser);
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
let error: unknown = false;
|
||||
provider
|
||||
.userEncryptor$(1, { singleUserId$ })
|
||||
.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
userKey$.error({ some: "error" });
|
||||
|
||||
expect(error).toEqual({ some: "error" });
|
||||
});
|
||||
|
||||
it("completes when `dependencies.singleUserId$` completes", () => {
|
||||
const userKey$ = new Subject<UserKey>();
|
||||
keyService.userKey$.mockReturnValue(userKey$);
|
||||
const singleUserId$ = new BehaviorSubject<UserId>(SomeUser);
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
let completed = false;
|
||||
provider
|
||||
.userEncryptor$(1, { singleUserId$ })
|
||||
.subscribe({ complete: () => (completed = true) });
|
||||
|
||||
singleUserId$.complete();
|
||||
|
||||
expect(completed).toBeTrue();
|
||||
});
|
||||
|
||||
it("completes when `userKey$` emits a falsy value after emitting a truthy value", () => {
|
||||
const userKey$ = new BehaviorSubject<UserKey>(SomeUserKey);
|
||||
keyService.userKey$.mockReturnValue(userKey$);
|
||||
const singleUserId$ = new BehaviorSubject<UserId>(SomeUser);
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
let completed = false;
|
||||
provider
|
||||
.userEncryptor$(1, { singleUserId$ })
|
||||
.subscribe({ complete: () => (completed = true) });
|
||||
|
||||
userKey$.next(null);
|
||||
|
||||
expect(completed).toBeTrue();
|
||||
});
|
||||
|
||||
it("completes once `dependencies.singleUserId$` emits and `userKey$` completes", () => {
|
||||
const userKey$ = new BehaviorSubject<UserKey>(SomeUserKey);
|
||||
keyService.userKey$.mockReturnValue(userKey$);
|
||||
const singleUserId$ = new BehaviorSubject<UserId>(SomeUser);
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
let completed = false;
|
||||
provider
|
||||
.userEncryptor$(1, { singleUserId$ })
|
||||
.subscribe({ complete: () => (completed = true) });
|
||||
|
||||
userKey$.complete();
|
||||
|
||||
expect(completed).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe("organizationEncryptor$", () => {
|
||||
it("emits an organization key encryptor bound to the organization", () => {
|
||||
const orgKey$ = new BehaviorSubject(OrgRecords);
|
||||
keyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
const singleOrganizationId$ = new BehaviorSubject<
|
||||
UserBound<"organizationId", OrganizationId>
|
||||
>({
|
||||
organizationId: SomeOrganization,
|
||||
userId: SomeUser,
|
||||
});
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = [];
|
||||
|
||||
provider
|
||||
.organizationEncryptor$(1, { singleOrganizationId$ })
|
||||
.subscribe((v) => results.push(v));
|
||||
|
||||
expect(keyService.orgKeys$).toHaveBeenCalledWith(SomeUser);
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]).toMatchObject({
|
||||
organizationId: SomeOrganization,
|
||||
encryptor: {
|
||||
organizationId: SomeOrganization,
|
||||
key: SomeOrgKey,
|
||||
dataPacker: { frameSize: 1 },
|
||||
},
|
||||
});
|
||||
expect(results[0].encryptor).toBeInstanceOf(OrganizationKeyEncryptor);
|
||||
});
|
||||
|
||||
it("waits until `dependencies.singleOrganizationId$` emits", () => {
|
||||
const orgKey$ = new BehaviorSubject(OrgRecords);
|
||||
keyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
const singleOrganizationId$ = new Subject<UserBound<"organizationId", OrganizationId>>();
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = [];
|
||||
provider
|
||||
.organizationEncryptor$(1, { singleOrganizationId$ })
|
||||
.subscribe((v) => results.push(v));
|
||||
// precondition: no emissions occur on subscribe
|
||||
expect(results.length).toBe(0);
|
||||
|
||||
singleOrganizationId$.next({
|
||||
organizationId: SomeOrganization,
|
||||
userId: SomeUser,
|
||||
});
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
});
|
||||
|
||||
it("emits a new organization key encryptor when `dependencies.singleOrganizationId$` emits", () => {
|
||||
const orgKey$ = new BehaviorSubject(OrgRecords);
|
||||
keyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
const singleOrganizationId$ = new Subject<UserBound<"organizationId", OrganizationId>>();
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = [];
|
||||
provider
|
||||
.organizationEncryptor$(1, { singleOrganizationId$ })
|
||||
.subscribe((v) => results.push(v));
|
||||
// precondition: no emissions occur on subscribe
|
||||
expect(results.length).toBe(0);
|
||||
|
||||
singleOrganizationId$.next({
|
||||
organizationId: SomeOrganization,
|
||||
userId: SomeUser,
|
||||
});
|
||||
singleOrganizationId$.next({
|
||||
organizationId: SomeOrganization,
|
||||
userId: SomeUser,
|
||||
});
|
||||
|
||||
expect(results.length).toBe(2);
|
||||
expect(results[0]).not.toBe(results[1]);
|
||||
});
|
||||
|
||||
it("waits until `orgKeys$` emits a truthy value", () => {
|
||||
const orgKey$ = new BehaviorSubject<Record<OrganizationId, OrgKey>>(null);
|
||||
keyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
const singleOrganizationId$ = new BehaviorSubject<
|
||||
UserBound<"organizationId", OrganizationId>
|
||||
>({
|
||||
organizationId: SomeOrganization,
|
||||
userId: SomeUser,
|
||||
});
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = [];
|
||||
provider
|
||||
.organizationEncryptor$(1, { singleOrganizationId$ })
|
||||
.subscribe((v) => results.push(v));
|
||||
// precondition: no emissions occur on subscribe
|
||||
expect(results.length).toBe(0);
|
||||
|
||||
orgKey$.next(OrgRecords);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0]).toMatchObject({
|
||||
organizationId: SomeOrganization,
|
||||
encryptor: {
|
||||
organizationId: SomeOrganization,
|
||||
key: SomeOrgKey,
|
||||
dataPacker: { frameSize: 1 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("emits an organization key encryptor each time `orgKeys$` emits", () => {
|
||||
const orgKey$ = new Subject<Record<OrganizationId, OrgKey>>();
|
||||
keyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
const singleOrganizationId$ = new BehaviorSubject<
|
||||
UserBound<"organizationId", OrganizationId>
|
||||
>({
|
||||
organizationId: SomeOrganization,
|
||||
userId: SomeUser,
|
||||
});
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = [];
|
||||
provider
|
||||
.organizationEncryptor$(1, { singleOrganizationId$ })
|
||||
.subscribe((v) => results.push(v));
|
||||
|
||||
orgKey$.next(OrgRecords);
|
||||
orgKey$.next(OrgRecords);
|
||||
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
it("errors when the userId changes", () => {
|
||||
const orgKey$ = new BehaviorSubject(OrgRecords);
|
||||
keyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
const singleOrganizationId$ = new Subject<UserBound<"organizationId", OrganizationId>>();
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
let error: unknown = false;
|
||||
provider
|
||||
.organizationEncryptor$(1, { singleOrganizationId$ })
|
||||
.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
singleOrganizationId$.next({ userId: SomeUser, organizationId: SomeOrganization });
|
||||
singleOrganizationId$.next({ userId: AnotherUser, organizationId: SomeOrganization });
|
||||
|
||||
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: AnotherUser });
|
||||
});
|
||||
|
||||
it("errors when the organizationId changes", () => {
|
||||
const orgKey$ = new BehaviorSubject(OrgRecords);
|
||||
keyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
const singleOrganizationId$ = new Subject<UserBound<"organizationId", OrganizationId>>();
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
let error: unknown = false;
|
||||
provider
|
||||
.organizationEncryptor$(1, { singleOrganizationId$ })
|
||||
.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
singleOrganizationId$.next({ userId: SomeUser, organizationId: SomeOrganization });
|
||||
singleOrganizationId$.next({ userId: SomeUser, organizationId: AnotherOrganization });
|
||||
|
||||
expect(error).toEqual({
|
||||
expectedOrganizationId: SomeOrganization,
|
||||
actualOrganizationId: AnotherOrganization,
|
||||
});
|
||||
});
|
||||
|
||||
it("errors when `dependencies.singleOrganizationId$` errors", () => {
|
||||
const orgKey$ = new BehaviorSubject(OrgRecords);
|
||||
keyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
const singleOrganizationId$ = new Subject<UserBound<"organizationId", OrganizationId>>();
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
let error: unknown = false;
|
||||
provider
|
||||
.organizationEncryptor$(1, { singleOrganizationId$ })
|
||||
.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
singleOrganizationId$.error({ some: "error" });
|
||||
|
||||
expect(error).toEqual({ some: "error" });
|
||||
});
|
||||
|
||||
it("errors once `dependencies.singleOrganizationId$` emits and `orgKeys$` errors", () => {
|
||||
const orgKey$ = new Subject<Record<OrganizationId, OrgKey>>();
|
||||
keyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
const singleOrganizationId$ = new BehaviorSubject<
|
||||
UserBound<"organizationId", OrganizationId>
|
||||
>({
|
||||
organizationId: SomeOrganization,
|
||||
userId: SomeUser,
|
||||
});
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
let error: unknown = false;
|
||||
provider
|
||||
.organizationEncryptor$(1, { singleOrganizationId$ })
|
||||
.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
orgKey$.error({ some: "error" });
|
||||
|
||||
expect(error).toEqual({ some: "error" });
|
||||
});
|
||||
|
||||
it("errors when the user lacks the requested org key", () => {
|
||||
const orgKey$ = new BehaviorSubject<Record<OrganizationId, OrgKey>>({});
|
||||
keyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
const singleOrganizationId$ = new BehaviorSubject<
|
||||
UserBound<"organizationId", OrganizationId>
|
||||
>({
|
||||
organizationId: SomeOrganization,
|
||||
userId: SomeUser,
|
||||
});
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
let error: unknown = false;
|
||||
|
||||
provider
|
||||
.organizationEncryptor$(1, { singleOrganizationId$ })
|
||||
.subscribe({ error: (e: unknown) => (error = e) });
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("completes when `dependencies.singleOrganizationId$` completes", () => {
|
||||
const orgKey$ = new BehaviorSubject(OrgRecords);
|
||||
keyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
const singleOrganizationId$ = new Subject<UserBound<"organizationId", OrganizationId>>();
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
let completed = false;
|
||||
provider
|
||||
.organizationEncryptor$(1, { singleOrganizationId$ })
|
||||
.subscribe({ complete: () => (completed = true) });
|
||||
|
||||
singleOrganizationId$.complete();
|
||||
|
||||
expect(completed).toBeTrue();
|
||||
});
|
||||
|
||||
it("completes when `orgKeys$` emits a falsy value after emitting a truthy value", () => {
|
||||
const orgKey$ = new Subject<Record<OrganizationId, OrgKey>>();
|
||||
keyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
const singleOrganizationId$ = new BehaviorSubject<
|
||||
UserBound<"organizationId", OrganizationId>
|
||||
>({
|
||||
organizationId: SomeOrganization,
|
||||
userId: SomeUser,
|
||||
});
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
let completed = false;
|
||||
provider
|
||||
.organizationEncryptor$(1, { singleOrganizationId$ })
|
||||
.subscribe({ complete: () => (completed = true) });
|
||||
|
||||
orgKey$.next(OrgRecords);
|
||||
orgKey$.next(null);
|
||||
|
||||
expect(completed).toBeTrue();
|
||||
});
|
||||
|
||||
it("completes once `dependencies.singleOrganizationId$` emits and `userKey$` completes", () => {
|
||||
const orgKey$ = new Subject<Record<OrganizationId, OrgKey>>();
|
||||
keyService.orgKeys$.mockReturnValue(orgKey$);
|
||||
const singleOrganizationId$ = new BehaviorSubject<
|
||||
UserBound<"organizationId", OrganizationId>
|
||||
>({
|
||||
organizationId: SomeOrganization,
|
||||
userId: SomeUser,
|
||||
});
|
||||
const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService);
|
||||
let completed = false;
|
||||
provider
|
||||
.organizationEncryptor$(1, { singleOrganizationId$ })
|
||||
.subscribe({ complete: () => (completed = true) });
|
||||
|
||||
orgKey$.complete();
|
||||
|
||||
expect(completed).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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$;
|
||||
}
|
||||
}
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
|
||||
/** An encryption strategy that protects a type's secrets with
|
||||
* user-specific keys. This strategy is bound to a specific user.
|
||||
*/
|
||||
export abstract class UserEncryptor {
|
||||
/** Identifies the user bound to the encryptor. */
|
||||
readonly userId: UserId;
|
||||
|
||||
/** Protects secrets in `value` with a user-specific key.
|
||||
* @param secret the object to protect. This object is mutated during encryption.
|
||||
* @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
libs/common/src/tools/cryptography/user-key-encryptor.spec.ts
Normal file
125
libs/common/src/tools/cryptography/user-key-encryptor.spec.ts
Normal file
@@ -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 { UserId } from "../../types/guid";
|
||||
import { UserKey } from "../../types/key";
|
||||
import { DataPacker } from "../state/data-packer.abstraction";
|
||||
|
||||
import { UserKeyEncryptor } from "./user-key-encryptor";
|
||||
|
||||
describe("UserKeyEncryptor", () => {
|
||||
const encryptService = mock<EncryptService>();
|
||||
const dataPacker = mock<DataPacker>();
|
||||
const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey;
|
||||
const anyUserId = "foo" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
// The UserKeyEncryptor 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 userId", async () => {
|
||||
const encryptor = new UserKeyEncryptor(anyUserId, encryptService, userKey, dataPacker);
|
||||
expect(encryptor.userId).toEqual(anyUserId);
|
||||
});
|
||||
|
||||
it("should throw if userId was not supplied", async () => {
|
||||
expect(() => new UserKeyEncryptor(null, encryptService, userKey, dataPacker)).toThrow(
|
||||
"userId cannot be null or undefined",
|
||||
);
|
||||
expect(() => new UserKeyEncryptor(null, encryptService, userKey, dataPacker)).toThrow(
|
||||
"userId cannot be null or undefined",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if encryptService was not supplied", async () => {
|
||||
expect(() => new UserKeyEncryptor(anyUserId, null, userKey, dataPacker)).toThrow(
|
||||
"encryptService cannot be null or undefined",
|
||||
);
|
||||
expect(() => new UserKeyEncryptor(anyUserId, null, userKey, dataPacker)).toThrow(
|
||||
"encryptService cannot be null or undefined",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if key was not supplied", async () => {
|
||||
expect(() => new UserKeyEncryptor(anyUserId, encryptService, null, dataPacker)).toThrow(
|
||||
"key cannot be null or undefined",
|
||||
);
|
||||
expect(() => new UserKeyEncryptor(anyUserId, encryptService, null, dataPacker)).toThrow(
|
||||
"key cannot be null or undefined",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if dataPacker was not supplied", async () => {
|
||||
expect(() => new UserKeyEncryptor(anyUserId, encryptService, userKey, null)).toThrow(
|
||||
"dataPacker cannot be null or undefined",
|
||||
);
|
||||
expect(() => new UserKeyEncryptor(anyUserId, encryptService, userKey, null)).toThrow(
|
||||
"dataPacker cannot be null or undefined",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encrypt", () => {
|
||||
it("should throw if value was not supplied", async () => {
|
||||
const encryptor = new UserKeyEncryptor(anyUserId, encryptService, userKey, dataPacker);
|
||||
|
||||
await expect(encryptor.encrypt<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(anyUserId, encryptService, userKey, 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, userKey);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrypt", () => {
|
||||
it("should throw if secret was not supplied", async () => {
|
||||
const encryptor = new UserKeyEncryptor(anyUserId, encryptService, userKey, 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 user's key", async () => {
|
||||
const encryptor = new UserKeyEncryptor(anyUserId, encryptService, userKey, 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, userKey);
|
||||
expect(dataPacker.unpack).toHaveBeenCalledWith(secret);
|
||||
expect(result).toBe(secret);
|
||||
});
|
||||
});
|
||||
});
|
||||
60
libs/common/src/tools/cryptography/user-key-encryptor.ts
Normal file
60
libs/common/src/tools/cryptography/user-key-encryptor.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { UserKey } from "../../types/key";
|
||||
import { DataPacker } from "../state/data-packer.abstraction";
|
||||
|
||||
import { UserEncryptor } from "./user-encryptor.abstraction";
|
||||
|
||||
/** A classification strategy that protects a type's secrets by encrypting them
|
||||
* with a `UserKey`
|
||||
*/
|
||||
export class UserKeyEncryptor extends UserEncryptor {
|
||||
/** Instantiates the encryptor
|
||||
* @param userId identifies the user bound to the encryptor.
|
||||
* @param encryptService protects properties of `Secret`.
|
||||
* @param keyService looks up the user key when protecting data.
|
||||
* @param dataPacker packs and unpacks data classified as secrets.
|
||||
*/
|
||||
constructor(
|
||||
readonly userId: UserId,
|
||||
private readonly encryptService: EncryptService,
|
||||
private readonly key: UserKey,
|
||||
private readonly dataPacker: DataPacker,
|
||||
) {
|
||||
super();
|
||||
this.assertHasValue("userId", userId);
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user