From ab21b78c53627425167d8a0a248180432c7351e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Thu, 28 Nov 2024 05:02:21 -0500 Subject: [PATCH] [PM-15061] extract encryptors from generator service (#12068) * introduce legacy encryptor provider * port credential generation service to encryptor provider --- ...-service-legacy-encryptor-provider.spec.ts | 492 ++++++++++++++++++ .../key-service-legacy-encryptor-provider.ts | 132 +++++ .../cryptography/legacy-encryptor-provider.ts | 42 ++ .../organization-encryptor.abstraction.ts | 33 ++ .../organization-key-encryptor.spec.ts | 125 +++++ .../organization-key-encryptor.ts | 60 +++ .../user-encryptor.abstraction.ts | 0 .../user-key-encryptor.spec.ts | 2 +- .../user-key-encryptor.ts | 2 +- libs/common/src/tools/dependencies.ts | 61 ++- libs/common/src/tools/rx.spec.ts | 99 ++++ libs/common/src/tools/rx.ts | 52 ++ libs/common/src/tools/state/object-key.ts | 25 +- .../src/tools/state/secret-state.spec.ts | 2 +- libs/common/src/tools/state/secret-state.ts | 2 +- .../tools/state/user-state-subject.spec.ts | 3 +- .../src/tools/state/user-state-subject.ts | 176 ++++--- .../src/forwarder-settings.component.html | 2 +- .../src/generator-services.module.ts | 54 ++ .../components/src/generator.module.ts | 39 +- libs/tools/generator/components/src/index.ts | 1 + .../generator/core/src/data/generators.ts | 2 +- .../generator/core/src/integration/addy-io.ts | 4 +- .../core/src/integration/duck-duck-go.ts | 3 +- .../core/src/integration/fastmail.ts | 3 +- .../core/src/integration/firefox-relay.ts | 3 +- .../core/src/integration/forward-email.ts | 3 +- .../core/src/integration/simple-login.ts | 4 +- .../credential-generator.service.spec.ts | 149 ++---- .../services/credential-generator.service.ts | 66 +-- .../forwarder-generator-strategy.ts | 2 +- .../src/local-generator-history.service.ts | 2 +- .../send-ui/src/send-form/send-form.module.ts | 38 +- 33 files changed, 1384 insertions(+), 299 deletions(-) create mode 100644 libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts create mode 100644 libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.ts create mode 100644 libs/common/src/tools/cryptography/legacy-encryptor-provider.ts create mode 100644 libs/common/src/tools/cryptography/organization-encryptor.abstraction.ts create mode 100644 libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts create mode 100644 libs/common/src/tools/cryptography/organization-key-encryptor.ts rename libs/common/src/tools/{state => cryptography}/user-encryptor.abstraction.ts (100%) rename libs/common/src/tools/{state => cryptography}/user-key-encryptor.spec.ts (98%) rename libs/common/src/tools/{state => cryptography}/user-key-encryptor.ts (96%) create mode 100644 libs/tools/generator/components/src/generator-services.module.ts diff --git a/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts b/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts new file mode 100644 index 0000000000..12257905d1 --- /dev/null +++ b/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts @@ -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(); +const keyService = mock(); + +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 = { + [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(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(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(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new Subject(); + 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(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new Subject(); + 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(null); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(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(); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(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(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new Subject(); + 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(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new Subject(); + 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(); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(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(); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(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(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(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(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(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>(); + 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>(); + 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>(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>(); + 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>(); + 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>(); + 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>(); + 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>(); + 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>({}); + 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>(); + 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>(); + 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>(); + 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(); + }); + }); +}); diff --git a/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.ts b/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.ts new file mode 100644 index 0000000000..f3d6c82ffc --- /dev/null +++ b/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.ts @@ -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(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>( + 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$; + } +} diff --git a/libs/common/src/tools/cryptography/legacy-encryptor-provider.ts b/libs/common/src/tools/cryptography/legacy-encryptor-provider.ts new file mode 100644 index 0000000000..5e83cb0671 --- /dev/null +++ b/libs/common/src/tools/cryptography/legacy-encryptor-provider.ts @@ -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>; + + /** 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>; +} diff --git a/libs/common/src/tools/cryptography/organization-encryptor.abstraction.ts b/libs/common/src/tools/cryptography/organization-encryptor.abstraction.ts new file mode 100644 index 0000000000..6884cdf38a --- /dev/null +++ b/libs/common/src/tools/cryptography/organization-encryptor.abstraction.ts @@ -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: Jsonify): Promise; + + /** 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: EncString): Promise>; +} diff --git a/libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts b/libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts new file mode 100644 index 0000000000..62c8ea24ae --- /dev/null +++ b/libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts @@ -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(); + const dataPacker = mock(); + 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((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>(null)).rejects.toThrow( + "secret cannot be null or undefined", + ); + await expect(encryptor.encrypt>(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); + }); + }); +}); diff --git a/libs/common/src/tools/cryptography/organization-key-encryptor.ts b/libs/common/src/tools/cryptography/organization-key-encryptor.ts new file mode 100644 index 0000000000..5bd7e36ee2 --- /dev/null +++ b/libs/common/src/tools/cryptography/organization-key-encryptor.ts @@ -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: Jsonify): Promise { + 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: EncString): Promise> { + this.assertHasValue("secret", secret); + + let decrypted = await this.encryptService.decryptToUtf8(secret, this.key); + const unpacked = this.dataPacker.unpack(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`); + } + } +} diff --git a/libs/common/src/tools/state/user-encryptor.abstraction.ts b/libs/common/src/tools/cryptography/user-encryptor.abstraction.ts similarity index 100% rename from libs/common/src/tools/state/user-encryptor.abstraction.ts rename to libs/common/src/tools/cryptography/user-encryptor.abstraction.ts diff --git a/libs/common/src/tools/state/user-key-encryptor.spec.ts b/libs/common/src/tools/cryptography/user-key-encryptor.spec.ts similarity index 98% rename from libs/common/src/tools/state/user-key-encryptor.spec.ts rename to libs/common/src/tools/cryptography/user-key-encryptor.spec.ts index 37c1155488..5b0ee5103c 100644 --- a/libs/common/src/tools/state/user-key-encryptor.spec.ts +++ b/libs/common/src/tools/cryptography/user-key-encryptor.spec.ts @@ -6,8 +6,8 @@ import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypt import { CsprngArray } from "../../types/csprng"; import { UserId } from "../../types/guid"; import { UserKey } from "../../types/key"; +import { DataPacker } from "../state/data-packer.abstraction"; -import { DataPacker } from "./data-packer.abstraction"; import { UserKeyEncryptor } from "./user-key-encryptor"; describe("UserKeyEncryptor", () => { diff --git a/libs/common/src/tools/state/user-key-encryptor.ts b/libs/common/src/tools/cryptography/user-key-encryptor.ts similarity index 96% rename from libs/common/src/tools/state/user-key-encryptor.ts rename to libs/common/src/tools/cryptography/user-key-encryptor.ts index d0316636d2..b2ccc51301 100644 --- a/libs/common/src/tools/state/user-key-encryptor.ts +++ b/libs/common/src/tools/cryptography/user-key-encryptor.ts @@ -5,8 +5,8 @@ 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 { DataPacker } from "./data-packer.abstraction"; import { UserEncryptor } from "./user-encryptor.abstraction"; /** A classification strategy that protects a type's secrets by encrypting them diff --git a/libs/common/src/tools/dependencies.ts b/libs/common/src/tools/dependencies.ts index 84e2f53fa2..cdae45bc94 100644 --- a/libs/common/src/tools/dependencies.ts +++ b/libs/common/src/tools/dependencies.ts @@ -1,9 +1,10 @@ import { Observable } from "rxjs"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { UserEncryptor } from "./state/user-encryptor.abstraction"; +import { OrganizationEncryptor } from "./cryptography/organization-encryptor.abstraction"; +import { UserEncryptor } from "./cryptography/user-encryptor.abstraction"; /** error emitted when the `SingleUserDependency` changes Ids */ export type UserChangedError = { @@ -13,6 +14,14 @@ export type UserChangedError = { actualUserId: UserId; }; +/** error emitted when the `SingleOrganizationDependency` changes Ids */ +export type OrganizationChangedError = { + /** the organizationId pinned by the single organization dependency */ + expectedOrganizationId: OrganizationId; + /** the organizationId received in error */ + actualOrganizationId: OrganizationId; +}; + /** A pattern for types that depend upon a dynamic policy stream and return * an observable. * @@ -55,6 +64,54 @@ export type UserBound = { [P in K]: T } & { userId: UserId; }; +/** Decorates a type to indicate the organization, if any, that the type is usable only by + * a specific organization. + */ +export type OrganizationBound = { [P in K]: T } & { + /** The organization to which T is bound. */ + organizationId: OrganizationId; +}; + +/** A pattern for types that depend upon a fixed-key encryptor and return + * an observable. + * + * Consumers of this dependency should emit a `OrganizationChangedError` if + * the bound OrganizationId changes or if the encryptor changes. If + * `singleOrganizationEncryptor$` completes, the consumer should complete + * once all events received prior to the completion event are + * finished processing. The consumer should, where possible, + * prioritize these events in order to complete as soon as possible. + * If `singleOrganizationEncryptor$` emits an unrecoverable error, the consumer + * should also emit the error. + */ +export type SingleOrganizationEncryptorDependency = { + /** A stream that emits an encryptor when subscribed and the org key + * is available, and completes when the org key is no longer available. + * The stream should not emit null or undefined. + */ + singleOrgEncryptor$: Observable>; +}; + +/** A pattern for types that depend upon a fixed-value organizationId and return + * an observable. + * + * Consumers of this dependency should emit a `OrganizationChangedError` if + * the value of `singleOrganizationId$` changes. If `singleOrganizationId$` completes, + * the consumer should also complete. If `singleOrganizationId$` errors, the + * consumer should also emit the error. + * + * @remarks Check the consumer's documentation to determine how it + * responds to repeat emissions. + */ +export type SingleOrganizationDependency = { + /** A stream that emits an organization Id and the user to which it is bound + * when subscribed and the user's account is unlocked, and completes when the + * account is locked or logged out. + * The stream should not emit null or undefined. + */ + singleOrganizationId$: Observable>; +}; + /** A pattern for types that depend upon a fixed-key encryptor and return * an observable. * diff --git a/libs/common/src/tools/rx.spec.ts b/libs/common/src/tools/rx.spec.ts index f6932f01dc..9ce147a3ff 100644 --- a/libs/common/src/tools/rx.spec.ts +++ b/libs/common/src/tools/rx.spec.ts @@ -8,6 +8,7 @@ import { awaitAsync, trackEmissions } from "../../spec"; import { anyComplete, + errorOnChange, distinctIfShallowMatch, on, ready, @@ -15,6 +16,104 @@ import { withLatestReady, } from "./rx"; +describe("errorOnChange", () => { + it("emits a single value when the input emits only once", async () => { + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); + + source$.next(1); + + expect(results).toEqual([1]); + }); + + it("emits when the input emits", async () => { + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); + + source$.next(1); + source$.next(1); + + expect(results).toEqual([1, 1]); + }); + + it("errors when the input errors", async () => { + const source$ = new Subject(); + const expected = {}; + let error: any = null; + source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); + + source$.error(expected); + + expect(error).toBe(expected); + }); + + it("completes when the input completes", async () => { + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(errorOnChange()).subscribe({ complete: () => (complete = true) }); + + source$.complete(); + + expect(complete).toBeTrue(); + }); + + it("errors when the input changes", async () => { + const source$ = new Subject(); + let error: any = null; + source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); + + source$.next(1); + source$.next(2); + + expect(error).toEqual({ expectedValue: 1, actualValue: 2 }); + }); + + it("emits when the extracted value remains constant", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + const results: Foo[] = []; + source$.pipe(errorOnChange((v) => v.foo)).subscribe((v) => results.push(v)); + + source$.next({ foo: "bar" }); + source$.next({ foo: "bar" }); + + expect(results).toEqual([{ foo: "bar" }, { foo: "bar" }]); + }); + + it("errors when an extracted value changes", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + let error: any = null; + source$.pipe(errorOnChange((v) => v.foo)).subscribe({ error: (v: unknown) => (error = v) }); + + source$.next({ foo: "bar" }); + source$.next({ foo: "baz" }); + + expect(error).toEqual({ expectedValue: "bar", actualValue: "baz" }); + }); + + it("constructs an error when the extracted value changes", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + let error: any = null; + source$ + .pipe( + errorOnChange( + (v) => v.foo, + (expected, actual) => ({ expected, actual }), + ), + ) + .subscribe({ error: (v: unknown) => (error = v) }); + + source$.next({ foo: "bar" }); + source$.next({ foo: "baz" }); + + expect(error).toEqual({ expected: "bar", actual: "baz" }); + }); +}); + describe("reduceCollection", () => { it.each([[null], [undefined], [[]]])( "should return the default value when the collection is %p", diff --git a/libs/common/src/tools/rx.ts b/libs/common/src/tools/rx.ts index d5d0b499ff..5c4f6a0a70 100644 --- a/libs/common/src/tools/rx.ts +++ b/libs/common/src/tools/rx.ts @@ -15,8 +15,60 @@ import { takeUntil, withLatestFrom, concatMap, + startWith, + pairwise, } from "rxjs"; +/** Returns its input. */ +function identity(value: any): any { + return value; +} + +/** Combines its arguments into a plain old javascript object. */ +function expectedAndActualValue(expectedValue: any, actualValue: any) { + return { + expectedValue, + actualValue, + }; +} + +/** + * An observable operator that throws an error when the stream's + * value changes. Uses strict (`===`) comparison checks. + * @param extract a function that identifies the member to compare; + * defaults to the identity function + * @param error a function that packages the expected and failed + * values into an error. + * @returns a stream of values that emits when the input emits, + * completes when the input completes, and errors when either the + * input errors or the comparison fails. + */ +export function errorOnChange( + extract: (value: Input) => Extracted = identity, + error: (expectedValue: Extracted, actualValue: Extracted) => unknown = expectedAndActualValue, +): OperatorFunction { + return pipe( + startWith(null), + pairwise(), + map(([expected, actual], i) => { + // always let the first value through + if (i === 0) { + return actual; + } + + const expectedValue = extract(expected); + const actualValue = extract(actual); + + // fail the stream if the state desyncs from its initial value + if (expectedValue === actualValue) { + return actual; + } else { + throw error(expectedValue, actualValue); + } + }), + ); +} + /** * An observable operator that reduces an emitted collection to a single object, * returning a default if all items are ignored. diff --git a/libs/common/src/tools/state/object-key.ts b/libs/common/src/tools/state/object-key.ts index 0593186ec4..260a2412b2 100644 --- a/libs/common/src/tools/state/object-key.ts +++ b/libs/common/src/tools/state/object-key.ts @@ -5,6 +5,17 @@ import type { StateDefinition } from "../../platform/state/state-definition"; import { ClassifiedFormat } from "./classified-format"; import { Classifier } from "./classifier"; +/** Determines the format of persistent storage. + * `plain` storage is a plain-old javascript object. Use this type + * when you are performing your own encryption and decryption. + * `classified` uses the `ClassifiedFormat` type as its format. + * `secret-state` uses `Array` with a length of 1. + * @remarks - CAUTION! If your on-disk data is not in a correct format, + * the storage system treats the data as corrupt and returns your initial + * value. + */ +export type ObjectStorageFormat = "plain" | "classified" | "secret-state"; + /** A key for storing JavaScript objects (`{ an: "example" }`) * in a UserStateSubject. */ @@ -20,7 +31,7 @@ export type ObjectKey> key: string; state: StateDefinition; classifier: Classifier; - format: "plain" | "classified"; + format: ObjectStorageFormat; options: UserKeyDefinitionOptions; initial?: State; }; @@ -47,6 +58,18 @@ export function toUserKeyDefinition( }, ); + return classified; + } else if (key.format === "secret-state") { + const classified = new UserKeyDefinition<[ClassifiedFormat]>( + key.state, + key.key, + { + cleanupDelayMs: key.options.cleanupDelayMs, + deserializer: (jsonValue) => jsonValue as [ClassifiedFormat], + clearOn: key.options.clearOn, + }, + ); + return classified; } else { throw new Error(`unknown format: ${key.format}`); diff --git a/libs/common/src/tools/state/secret-state.spec.ts b/libs/common/src/tools/state/secret-state.spec.ts index d4727492b3..5f679644fc 100644 --- a/libs/common/src/tools/state/secret-state.spec.ts +++ b/libs/common/src/tools/state/secret-state.spec.ts @@ -11,11 +11,11 @@ import { import { EncString } from "../../platform/models/domain/enc-string"; import { GENERATOR_DISK } from "../../platform/state"; import { UserId } from "../../types/guid"; +import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; import { SecretClassifier } from "./secret-classifier"; import { SecretKeyDefinition } from "./secret-key-definition"; import { SecretState } from "./secret-state"; -import { UserEncryptor } from "./user-encryptor.abstraction"; type FooBar = { foo: boolean; bar: boolean; date?: Date }; const classifier = SecretClassifier.allSecret(); diff --git a/libs/common/src/tools/state/secret-state.ts b/libs/common/src/tools/state/secret-state.ts index 45ce855cc8..fe7c025ccf 100644 --- a/libs/common/src/tools/state/secret-state.ts +++ b/libs/common/src/tools/state/secret-state.ts @@ -8,10 +8,10 @@ import { CombinedState, } from "../../platform/state"; import { UserId } from "../../types/guid"; +import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; import { ClassifiedFormat } from "./classified-format"; import { SecretKeyDefinition } from "./secret-key-definition"; -import { UserEncryptor } from "./user-encryptor.abstraction"; const ONE_MINUTE = 1000 * 60; diff --git a/libs/common/src/tools/state/user-state-subject.spec.ts b/libs/common/src/tools/state/user-state-subject.spec.ts index ee78a5c048..6a50a1dd66 100644 --- a/libs/common/src/tools/state/user-state-subject.spec.ts +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -4,13 +4,13 @@ import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/st import { UserId } from "@bitwarden/common/types/guid"; import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec"; +import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; import { UserBound } from "../dependencies"; import { PrivateClassifier } from "../private-classifier"; import { StateConstraints } from "../types"; import { ClassifiedFormat } from "./classified-format"; import { ObjectKey } from "./object-key"; -import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserStateSubject } from "./user-state-subject"; const SomeUser = "some user" as UserId; @@ -734,6 +734,7 @@ describe("UserStateSubject", () => { error = e as any; }, }); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor }); singleUserEncryptor$.next({ userId: errorUserId, encryptor: SomeEncryptor }); await awaitAsync(); diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 0b562cc7a1..4a2dab1234 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -6,10 +6,8 @@ import { filter, map, takeUntil, - pairwise, distinctUntilChanged, BehaviorSubject, - startWith, Observable, Subscription, last, @@ -30,15 +28,15 @@ import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SingleUserState, UserKeyDefinition } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; +import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; import { UserBound } from "../dependencies"; -import { anyComplete, ready, withLatestReady } from "../rx"; +import { anyComplete, errorOnChange, ready, withLatestReady } from "../rx"; import { Constraints, SubjectConstraints, WithConstraints } from "../types"; import { ClassifiedFormat, isClassifiedFormat } from "./classified-format"; import { unconstrained$ } from "./identity-state-constraint"; import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./object-key"; import { isDynamic } from "./state-constraints-dependency"; -import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"; type Constrained = { constraints: Readonly>; state: State }; @@ -195,24 +193,13 @@ export class UserStateSubject< } }), // fail the stream if the state desyncs from the bound userId - startWith({ userId: this.state.userId, encryptor: null } as UserBound< - "encryptor", - UserEncryptor - >), - pairwise(), - map(([expected, actual]) => { - if (expected.userId === actual.userId) { - return actual; - } else { - throw { - expectedUserId: expected.userId, - actualUserId: actual.userId, - }; - } - }), + errorOnChange( + ({ userId }) => userId, + (expectedUserId, actualUserId) => ({ expectedUserId, actualUserId }), + ), // reduce emissions to when encryptor changes - distinctUntilChanged(), map(({ encryptor }) => encryptor), + distinctUntilChanged(), ); } @@ -317,36 +304,63 @@ export class UserStateSubject< return (input$) => input$ as Observable; } - // if the key supports encryption, enable encryptor support + // all other keys support encryption; enable encryptor support + return pipe( + this.mapToClassifiedFormat(), + combineLatestWith(encryptor$), + concatMap(async ([input, encryptor]) => { + // pass through null values + if (input === null || input === undefined) { + return null; + } + + // decrypt classified data + const { secret, disclosed } = input; + const encrypted = EncString.fromJSON(secret); + const decryptedSecret = await encryptor.decrypt(encrypted); + + // assemble into proper state + const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret); + const state = this.objectKey.options.deserializer(declassified); + + return state; + }), + ); + } + + private mapToClassifiedFormat(): OperatorFunction> { + // FIXME: warn when data is dropped in the console and/or report an error + // through the observable; consider redirecting dropped data to a recovery + // location + + // user-state subject's default format is object-aware if (this.objectKey && this.objectKey.format === "classified") { - return pipe( - combineLatestWith(encryptor$), - concatMap(async ([input, encryptor]) => { - // pass through null values - if (input === null || input === undefined) { - return null; - } + return map((input) => { + if (!isClassifiedFormat(input)) { + return null; + } - // fail fast if the format is incorrect - if (!isClassifiedFormat(input)) { - throw new Error(`Cannot declassify ${this.key.key}; unknown format.`); - } - - // decrypt classified data - const { secret, disclosed } = input; - const encrypted = EncString.fromJSON(secret); - const decryptedSecret = await encryptor.decrypt(encrypted); - - // assemble into proper state - const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret); - const state = this.objectKey.options.deserializer(declassified); - - return state; - }), - ); + return input; + }); } - throw new Error(`unknown serialization format: ${this.objectKey.format}`); + // secret state's format wraps objects in an array + if (this.objectKey && this.objectKey.format === "secret-state") { + return map((input) => { + if (!Array.isArray(input)) { + return null; + } + + const [unwrapped] = input; + if (!isClassifiedFormat(unwrapped)) { + return null; + } + + return unwrapped; + }); + } + + throw new Error(`unsupported serialization format: ${this.objectKey.format}`); } private classify(encryptor$: Observable): OperatorFunction { @@ -359,41 +373,49 @@ export class UserStateSubject< ); } - // if the key supports encryption, enable encryptor support + // all other keys support encryption; enable encryptor support + return pipe( + withLatestReady(encryptor$), + concatMap(async ([input, encryptor]) => { + // fail fast if there's no value + if (input === null || input === undefined) { + return null; + } + + // split data by classification level + const serialized = JSON.parse(JSON.stringify(input)); + const classified = this.objectKey.classifier.classify(serialized); + + // protect data + const encrypted = await encryptor.encrypt(classified.secret); + const secret = JSON.parse(JSON.stringify(encrypted)); + + // wrap result in classified format envelope for storage + const envelope = { + id: null as void, + secret, + disclosed: classified.disclosed, + } satisfies ClassifiedFormat; + + // deliberate type erasure; the type is restored during `declassify` + return envelope as ClassifiedFormat; + }), + this.mapToStorageFormat(), + ); + } + + private mapToStorageFormat(): OperatorFunction, unknown> { + // user-state subject's default format is object-aware if (this.objectKey && this.objectKey.format === "classified") { - return pipe( - withLatestReady(encryptor$), - concatMap(async ([input, encryptor]) => { - // fail fast if there's no value - if (input === null || input === undefined) { - return null; - } - - // split data by classification level - const serialized = JSON.parse(JSON.stringify(input)); - const classified = this.objectKey.classifier.classify(serialized); - - // protect data - const encrypted = await encryptor.encrypt(classified.secret); - const secret = JSON.parse(JSON.stringify(encrypted)); - - // wrap result in classified format envelope for storage - const envelope = { - id: null as void, - secret, - disclosed: classified.disclosed, - } satisfies ClassifiedFormat; - - // deliberate type erasure; the type is restored during `declassify` - return envelope as unknown; - }), - ); + return map((input) => input as unknown); } - // FIXME: add "encrypted" format --> key contains encryption logic - // CONSIDER: should "classified format" algorithm be embedded in subject keys...? + // secret state's format wraps objects in an array + if (this.objectKey && this.objectKey.format === "secret-state") { + return map((input) => [input] as unknown); + } - throw new Error(`unknown serialization format: ${this.objectKey.format}`); + throw new Error(`unsupported serialization format: ${this.objectKey.format}`); } /** The userId to which the subject is bound. diff --git a/libs/tools/generator/components/src/forwarder-settings.component.html b/libs/tools/generator/components/src/forwarder-settings.component.html index 0e15c2e89a..d610f53d59 100644 --- a/libs/tools/generator/components/src/forwarder-settings.component.html +++ b/libs/tools/generator/components/src/forwarder-settings.component.html @@ -12,7 +12,7 @@ {{ "apiKey" | i18n }} - +