From eff9a423dabc49735966b5031f3d94bc6e0e9605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Wed, 23 Oct 2024 12:11:42 -0400 Subject: [PATCH] [PM-8280] email forwarders (#11563) * forwarder lookup and generation support * localize algorithm names and descriptions in the credential generator service * add encryption support to UserStateSubject * move generic rx utilities to common * move icon button labels to generator configurations --- apps/browser/src/_locales/en/messages.json | 15 + apps/desktop/src/locales/en/messages.json | 18 + apps/web/src/locales/en/messages.json | 15 + libs/common/src/tools/dependencies.ts | 32 +- .../src/tools/integration/integration-id.ts | 14 +- libs/common/src/tools/private-classifier.ts | 31 + libs/common/src/tools/public-classifier.ts | 29 + libs/common/src/tools/rx.spec.ts | 496 +++++++++++++++- libs/common/src/tools/rx.ts | 125 +++- .../src/tools/state/classified-format.ts | 6 + .../tools/state/identity-state-constraint.ts | 26 +- libs/common/src/tools/state/object-key.ts | 53 ++ .../state/state-constraints-dependency.ts | 6 +- .../state/user-state-subject-dependencies.ts | 18 +- .../tools/state/user-state-subject.spec.ts | 315 +++++++--- .../src/tools/state/user-state-subject.ts | 416 +++++++++---- libs/common/src/tools/types.ts | 9 +- .../src/credential-generator.component.html | 27 +- .../src/credential-generator.component.ts | 376 ++++++++---- .../src/forwarder-settings.component.html | 16 + .../src/forwarder-settings.component.ts | 195 +++++++ .../components/src/generator.module.ts | 19 +- .../src/password-generator.component.ts | 54 +- .../src/username-generator.component.html | 23 +- .../src/username-generator.component.ts | 278 +++++++-- libs/tools/generator/components/src/util.ts | 2 +- .../core/src/data/generator-types.ts | 2 +- .../generator/core/src/data/generators.ts | 98 +++- .../generator/core/src/data/integrations.ts | 23 + .../src/engine/forwarder-configuration.ts | 35 +- .../generator/core/src/engine/forwarder.ts | 75 +++ .../generator/core/src/integration/addy-io.ts | 44 +- .../core/src/integration/duck-duck-go.ts | 42 +- .../core/src/integration/fastmail.ts | 46 +- .../core/src/integration/firefox-relay.ts | 42 +- .../core/src/integration/forward-email.ts | 41 +- .../core/src/integration/simple-login.ts | 42 +- libs/tools/generator/core/src/rx.spec.ts | 352 ----------- libs/tools/generator/core/src/rx.ts | 99 +--- .../credential-generator.service.spec.ts | 546 +++++++++++++++--- .../services/credential-generator.service.ts | 181 ++++-- .../credential-generator-configuration.ts | 78 ++- .../core/src/types/generator-type.ts | 31 +- libs/tools/generator/core/src/types/index.ts | 4 +- .../send-ui/src/send-form/send-form.module.ts | 13 +- 45 files changed, 3403 insertions(+), 1005 deletions(-) create mode 100644 libs/common/src/tools/private-classifier.ts create mode 100644 libs/common/src/tools/public-classifier.ts create mode 100644 libs/common/src/tools/state/object-key.ts create mode 100644 libs/tools/generator/components/src/forwarder-settings.component.html create mode 100644 libs/tools/generator/components/src/forwarder-settings.component.ts create mode 100644 libs/tools/generator/core/src/engine/forwarder.ts delete mode 100644 libs/tools/generator/core/src/rx.spec.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 7fb21952dd..e72daaa171 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1395,6 +1395,10 @@ "baseUrl": { "message": "Server URL" }, + "selfHostBaseUrl": { + "message": "Self-host server URL", + "description": "Label for field requesting a self-hosted integration service URL" + }, "apiUrl": { "message": "API server URL" }, @@ -2833,6 +2837,9 @@ "generateUsername": { "message": "Generate username" }, + "generateEmail": { + "message": "Generate email" + }, "usernameType": { "message": "Username type" }, @@ -2873,6 +2880,14 @@ "forwardedEmailDesc": { "message": "Generate an email alias with an external forwarding service." }, + "forwarderDomainName": { + "message": "Email domain", + "description": "Labels the domain name email forwarder service option" + }, + "forwarderDomainNameHint": { + "message": "Choose a domain that is supported by the selected service", + "description": "Guidance provided for email forwarding services that support multiple email domains." + }, "forwarderError": { "message": "$SERVICENAME$ error: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index f119d7366d..e04941bdb9 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -835,6 +835,10 @@ "baseUrl": { "message": "Server URL" }, + "selfHostBaseUrl": { + "message": "Self-host server URL", + "description": "Label for field requesting a self-hosted integration service URL" + }, "apiUrl": { "message": "API server URL" }, @@ -1225,6 +1229,9 @@ "message": "Copy number", "description": "Copy credit card number" }, + "copyEmail": { + "message": "Copy email" + }, "copySecurityCode": { "message": "Copy security code", "description": "Copy credit card security code (CVV)" @@ -2359,6 +2366,9 @@ "generateUsername": { "message": "Generate username" }, + "generateEmail": { + "message": "Generate email" + }, "usernameType": { "message": "Username type" }, @@ -2402,6 +2412,14 @@ "forwardedEmailDesc": { "message": "Generate an email alias with an external forwarding service." }, + "forwarderDomainName": { + "message": "Email domain", + "description": "Labels the domain name email forwarder service option" + }, + "forwarderDomainNameHint": { + "message": "Choose a domain that is supported by the selected service", + "description": "Guidance provided for email forwarding services that support multiple email domains." + }, "forwarderError": { "message": "$SERVICENAME$ error: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index a27f13f9ae..07d94892ad 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -6361,6 +6361,9 @@ "generateUsername": { "message": "Generate username" }, + "generateEmail": { + "message": "Generate email" + }, "usernameType": { "message": "Username type" }, @@ -6466,6 +6469,14 @@ "forwardedEmailDesc": { "message": "Generate an email alias with an external forwarding service." }, + "forwarderDomainName": { + "message": "Email domain", + "description": "Labels the domain name email forwarder service option" + }, + "forwarderDomainNameHint": { + "message": "Choose a domain that is supported by the selected service", + "description": "Guidance provided for email forwarding services that support multiple email domains." + }, "forwarderError": { "message": "$SERVICENAME$ error: $ERRORMESSAGE$", "description": "Reports an error returned by a forwarding service to the user.", @@ -8265,6 +8276,10 @@ "baseUrl": { "message": "Server URL" }, + "selfHostBaseUrl": { + "message": "Self-host server URL", + "description": "Label for field requesting a self-hosted integration service URL" + }, "aliasDomain": { "message": "Alias domain" }, diff --git a/libs/common/src/tools/dependencies.ts b/libs/common/src/tools/dependencies.ts index 8b860591d5..84e2f53fa2 100644 --- a/libs/common/src/tools/dependencies.ts +++ b/libs/common/src/tools/dependencies.ts @@ -3,6 +3,8 @@ import { Observable } from "rxjs"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { UserId } from "@bitwarden/common/types/guid"; +import { UserEncryptor } from "./state/user-encryptor.abstraction"; + /** error emitted when the `SingleUserDependency` changes Ids */ export type UserChangedError = { /** the userId pinned by the single user dependency */ @@ -45,7 +47,35 @@ export type UserDependency = { userId$: Observable; }; -/** A pattern for types that depend upon a fixed userid and return +/** Decorates a type to indicate the user, if any, that the type is usable only by + * a specific user. + */ +export type UserBound = { [P in K]: T } & { + /** The user to which T is bound. */ + userId: UserId; +}; + +/** A pattern for types that depend upon a fixed-key encryptor and return + * an observable. + * + * Consumers of this dependency should emit a `UserChangedError` if + * the bound UserId changes or if the encryptor changes. If + * `singleUserEncryptor$` 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 `singleUserEncryptor$` emits an unrecoverable error, the consumer + * should also emit the error. + */ +export type SingleUserEncryptorDependency = { + /** A stream that emits an encryptor when subscribed and the user key + * is available, and completes when the user key is no longer available. + * The stream should not emit null or undefined. + */ + singleUserEncryptor$: Observable>; +}; + +/** A pattern for types that depend upon a fixed-value userid and return * an observable. * * Consumers of this dependency should emit a `UserChangedError` if diff --git a/libs/common/src/tools/integration/integration-id.ts b/libs/common/src/tools/integration/integration-id.ts index 46b81c3c4c..a15db143ee 100644 --- a/libs/common/src/tools/integration/integration-id.ts +++ b/libs/common/src/tools/integration/integration-id.ts @@ -1,7 +1,13 @@ import { Opaque } from "type-fest"; +export const IntegrationIds = [ + "anonaddy", + "duckduckgo", + "fastmail", + "firefoxrelay", + "forwardemail", + "simplelogin", +] as const; + /** Identifies a vendor integrated into bitwarden */ -export type IntegrationId = Opaque< - "anonaddy" | "duckduckgo" | "fastmail" | "firefoxrelay" | "forwardemail" | "simplelogin", - "IntegrationId" ->; +export type IntegrationId = Opaque<(typeof IntegrationIds)[number], "IntegrationId">; diff --git a/libs/common/src/tools/private-classifier.ts b/libs/common/src/tools/private-classifier.ts new file mode 100644 index 0000000000..f9648504b7 --- /dev/null +++ b/libs/common/src/tools/private-classifier.ts @@ -0,0 +1,31 @@ +import { Jsonify } from "type-fest"; + +import { Classifier } from "@bitwarden/common/tools/state/classifier"; + +export class PrivateClassifier implements Classifier, Data> { + constructor(private keys: (keyof Jsonify)[] = undefined) {} + + classify(value: Data): { disclosed: Jsonify>; secret: Jsonify } { + const pickMe = JSON.parse(JSON.stringify(value)); + const keys: (keyof Jsonify)[] = this.keys ?? (Object.keys(pickMe) as any); + + const picked: Partial> = {}; + for (const key of keys) { + picked[key] = pickMe[key]; + } + const secret = picked as Jsonify; + + return { disclosed: null, secret }; + } + + declassify(_disclosed: Jsonify>, secret: Jsonify) { + const result: Partial> = {}; + const keys: (keyof Jsonify)[] = this.keys ?? (Object.keys(secret) as any); + + for (const key of keys) { + result[key] = secret[key]; + } + + return result as Jsonify; + } +} diff --git a/libs/common/src/tools/public-classifier.ts b/libs/common/src/tools/public-classifier.ts new file mode 100644 index 0000000000..82396f1c16 --- /dev/null +++ b/libs/common/src/tools/public-classifier.ts @@ -0,0 +1,29 @@ +import { Jsonify } from "type-fest"; + +import { Classifier } from "@bitwarden/common/tools/state/classifier"; + +export class PublicClassifier implements Classifier> { + constructor(private keys: (keyof Jsonify)[]) {} + + classify(value: Data): { disclosed: Jsonify; secret: Jsonify> } { + const pickMe = JSON.parse(JSON.stringify(value)); + + const picked: Partial> = {}; + for (const key of this.keys) { + picked[key] = pickMe[key]; + } + const disclosed = picked as Jsonify; + + return { disclosed, secret: null }; + } + + declassify(disclosed: Jsonify, _secret: Jsonify>) { + const result: Partial> = {}; + + for (const key of this.keys) { + result[key] = disclosed[key]; + } + + return result as Jsonify; + } +} diff --git a/libs/common/src/tools/rx.spec.ts b/libs/common/src/tools/rx.spec.ts index 8a2c1e38f5..f6932f01dc 100644 --- a/libs/common/src/tools/rx.spec.ts +++ b/libs/common/src/tools/rx.spec.ts @@ -2,11 +2,18 @@ * include structuredClone in test environment. * @jest-environment ../../../../shared/test.environment.ts */ -import { of, firstValueFrom } from "rxjs"; +import { of, firstValueFrom, Subject, tap, EmptyError } from "rxjs"; import { awaitAsync, trackEmissions } from "../../spec"; -import { distinctIfShallowMatch, reduceCollection } from "./rx"; +import { + anyComplete, + distinctIfShallowMatch, + on, + ready, + reduceCollection, + withLatestReady, +} from "./rx"; describe("reduceCollection", () => { it.each([[null], [undefined], [[]]])( @@ -84,3 +91,488 @@ describe("distinctIfShallowMatch", () => { expect(result).toEqual([{ foo: true, bar: true }]); }); }); + +describe("anyComplete", () => { + it("emits true when its input completes", () => { + const input$ = new Subject(); + + const emissions: boolean[] = []; + anyComplete(input$).subscribe((e) => emissions.push(e)); + input$.complete(); + + expect(emissions).toEqual([true]); + }); + + it("completes when its input is already complete", () => { + const input = new Subject(); + input.complete(); + + let completed = false; + anyComplete(input).subscribe({ complete: () => (completed = true) }); + + expect(completed).toBe(true); + }); + + it("completes when any input completes", () => { + const input$ = new Subject(); + const completing$ = new Subject(); + + let completed = false; + anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) }); + completing$.complete(); + + expect(completed).toBe(true); + }); + + it("ignores emissions", () => { + const input$ = new Subject(); + + const emissions: boolean[] = []; + anyComplete(input$).subscribe((e) => emissions.push(e)); + input$.next(1); + input$.next(2); + input$.complete(); + + expect(emissions).toEqual([true]); + }); + + it("forwards errors", () => { + const input$ = new Subject(); + const expected = { some: "error" }; + + let error = null; + anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) }); + input$.error(expected); + + expect(error).toEqual(expected); + }); +}); + +describe("ready", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: ready$ should be cold + const ready$ = source$.pipe(ready(watch$)); + expect(connected).toBe(false); + + ready$.subscribe(); + + expect(connected).toBe(true); + }); + + it("suppresses source emissions until its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + watch$.next(); + + expect(results).toEqual([1]); + }); + + it("suppresses source emissions until all watches emit", () => { + const watchA$ = new Subject(); + const watchB$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready([watchA$, watchB$])); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // preconditions: no emissions + source$.next(1); + expect(results).toEqual([]); + watchA$.next(); + expect(results).toEqual([]); + + watchB$.next(); + + expect(results).toEqual([1]); + }); + + it("emits the last source emission when its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + source$.next(2); + watch$.next(); + + expect(results).toEqual([2]); + }); + + it("emits all source emissions after its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + source$.next(2); + + expect(results).toEqual([1, 2]); + }); + + it("ignores repeated watch emissions", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const results: number[] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + watch$.next(); + source$.next(2); + watch$.next(); + + expect(results).toEqual([1, 2]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + let completed = false; + ready$.subscribe({ complete: () => (completed = true) }); + + source$.complete(); + + expect(completed).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch completes before emitting", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(ready(watch$)); + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.complete(); + + expect(error).toBeInstanceOf(EmptyError); + }); +}); + +describe("withLatestReady", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: ready$ should be cold + const ready$ = source$.pipe(withLatestReady(watch$)); + expect(connected).toBe(false); + + ready$.subscribe(); + + expect(connected).toBe(true); + }); + + it("suppresses source emissions until its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + watch$.next("watch"); + + expect(results).toEqual([[1, "watch"]]); + }); + + it("emits the last source emission when its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + // precondition: no emissions + source$.next(1); + expect(results).toEqual([]); + + source$.next(2); + watch$.next("watch"); + + expect(results).toEqual([[2, "watch"]]); + }); + + it("emits all source emissions after its watch emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next("watch"); + source$.next(1); + source$.next(2); + + expect(results).toEqual([ + [1, "watch"], + [2, "watch"], + ]); + }); + + it("appends the latest watch emission", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const results: [number, string][] = []; + ready$.subscribe((n) => results.push(n)); + + watch$.next("ignored"); + watch$.next("watch"); + source$.next(1); + watch$.next("ignored"); + watch$.next("watch"); + source$.next(2); + + expect(results).toEqual([ + [1, "watch"], + [2, "watch"], + ]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + let completed = false; + ready$.subscribe({ complete: () => (completed = true) }); + + source$.complete(); + + expect(completed).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + const expected = { some: "error" }; + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch completes before emitting", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const ready$ = source$.pipe(withLatestReady(watch$)); + let error = null; + ready$.subscribe({ error: (e: unknown) => (error = e) }); + + watch$.complete(); + + expect(error).toBeInstanceOf(EmptyError); + }); +}); + +describe("on", () => { + it("connects when subscribed", () => { + const watch$ = new Subject(); + let connected = false; + const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); + + // precondition: on$ should be cold + const on$ = source$.pipe(on(watch$)); + expect(connected).toBeFalsy(); + + on$.subscribe(); + + expect(connected).toBeTruthy(); + }); + + it("suppresses source emissions until `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + // precondition: on$ should be cold + source$.next(1); + expect(results).toEqual([]); + + watch$.next(); + + expect(results).toEqual([1]); + }); + + it("repeats source emissions when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + source$.next(1); + + watch$.next(); + watch$.next(); + + expect(results).toEqual([1, 1]); + }); + + it("updates source emissions when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + source$.next(1); + watch$.next(); + source$.next(2); + watch$.next(); + + expect(results).toEqual([1, 2]); + }); + + it("emits a value when `on` emits before the source is ready", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + watch$.next(); + source$.next(1); + + expect(results).toEqual([1]); + }); + + it("ignores repeated `on` emissions before the source is ready", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + + watch$.next(); + watch$.next(); + source$.next(1); + + expect(results).toEqual([1]); + }); + + it("emits only the latest source emission when `on` emits", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(on(watch$)).subscribe((n) => results.push(n)); + source$.next(1); + + watch$.next(); + + source$.next(2); + source$.next(3); + watch$.next(); + + expect(results).toEqual([1, 3]); + }); + + it("completes when its source completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); + + source$.complete(); + + expect(complete).toBeTruthy(); + }); + + it("completes when its watch completes", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); + + watch$.complete(); + + expect(complete).toBeTruthy(); + }); + + it("errors when its source errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const expected = { some: "error" }; + let error = null; + source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); + + source$.error(expected); + + expect(error).toEqual(expected); + }); + + it("errors when its watch errors", () => { + const watch$ = new Subject(); + const source$ = new Subject(); + const expected = { some: "error" }; + let error = null; + source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); + + watch$.error(expected); + + expect(error).toEqual(expected); + }); +}); diff --git a/libs/common/src/tools/rx.ts b/libs/common/src/tools/rx.ts index d2c5747a88..d5d0b499ff 100644 --- a/libs/common/src/tools/rx.ts +++ b/libs/common/src/tools/rx.ts @@ -1,4 +1,21 @@ -import { map, distinctUntilChanged, OperatorFunction } from "rxjs"; +import { + map, + distinctUntilChanged, + OperatorFunction, + Observable, + ignoreElements, + endWith, + race, + pipe, + connect, + ReplaySubject, + concat, + zip, + first, + takeUntil, + withLatestFrom, + concatMap, +} from "rxjs"; /** * An observable operator that reduces an emitted collection to a single object, @@ -36,3 +53,109 @@ export function distinctIfShallowMatch(): OperatorFunction { return isDistinct; }); } + +/** Create an observable that, once subscribed, emits `true` then completes when + * any input completes. If an input is already complete when the subscription + * occurs, it emits immediately. + * @param watch$ the observable(s) to watch for completion; if an array is passed, + * null and undefined members are ignored. If `watch$` is empty, `anyComplete` + * will never complete. + * @returns An observable that emits `true` when any of its inputs + * complete. The observable forwards the first error from its input. + * @remarks This method is particularly useful in combination with `takeUntil` and + * streams that are not guaranteed to complete on their own. + */ +export function anyComplete(watch$: Observable | Observable[]): Observable { + if (Array.isArray(watch$)) { + const completes$ = watch$ + .filter((w$) => !!w$) + .map((w$) => w$.pipe(ignoreElements(), endWith(true))); + const completed$ = race(completes$); + return completed$; + } else { + return watch$.pipe(ignoreElements(), endWith(true)); + } +} + +/** + * Create an observable that delays the input stream until all watches have + * emitted a value. The watched values are not included in the source stream. + * The last emission from the source is output when all the watches have + * emitted at least once. + * @param watch$ the observable(s) to watch for readiness. If `watch$` is empty, + * `ready` will never emit. + * @returns An observable that emits when the source stream emits. The observable + * errors if one of its watches completes before emitting. It also errors if one + * of its watches errors. + */ +export function ready(watch$: Observable | Observable[]) { + const watching$ = Array.isArray(watch$) ? watch$ : [watch$]; + return pipe( + connect>((source$) => { + // this subscription is safe because `source$` connects only after there + // is an external subscriber. + const source = new ReplaySubject(1); + source$.subscribe(source); + + // `concat` is subscribed immediately after it's returned, at which point + // `zip` blocks until all items in `watching$` are ready. If that occurs + // after `source$` is hot, then the replay subject sends the last-captured + // emission through immediately. Otherwise, `ready` waits for the next + // emission + return concat(zip(watching$).pipe(first(), ignoreElements()), source).pipe( + takeUntil(anyComplete(source)), + ); + }), + ); +} + +export function withLatestReady( + watch$: Observable, +): OperatorFunction { + return connect((source$) => { + // these subscriptions are safe because `source$` connects only after there + // is an external subscriber. + const source = new ReplaySubject(1); + source$.subscribe(source); + const watch = new ReplaySubject(1); + watch$.subscribe(watch); + + // `concat` is subscribed immediately after it's returned, at which point + // `zip` blocks until all items in `watching$` are ready. If that occurs + // after `source$` is hot, then the replay subject sends the last-captured + // emission through immediately. Otherwise, `ready` waits for the next + // emission + return concat(zip(watch).pipe(first(), ignoreElements()), source).pipe( + withLatestFrom(watch), + takeUntil(anyComplete(source)), + ); + }); +} + +/** + * Create an observable that emits the latest value of the source stream + * when `watch$` emits. If `watch$` emits before the stream emits, then + * an emission occurs as soon as a value becomes ready. + * @param watch$ the observable that triggers emissions + * @returns An observable that emits when `watch$` emits. The observable + * errors if its source stream errors. It also errors if `on` errors. It + * completes if its watch completes. + * + * @remarks This works like `audit`, but it repeats emissions when + * watch$ fires. + */ +export function on(watch$: Observable) { + return pipe( + connect>((source$) => { + const source = new ReplaySubject(1); + source$.subscribe(source); + + return watch$ + .pipe( + ready(source), + concatMap(() => source.pipe(first())), + ) + .pipe(takeUntil(anyComplete(source))); + }), + ); +} diff --git a/libs/common/src/tools/state/classified-format.ts b/libs/common/src/tools/state/classified-format.ts index 93147a0fb5..26aca0197c 100644 --- a/libs/common/src/tools/state/classified-format.ts +++ b/libs/common/src/tools/state/classified-format.ts @@ -17,3 +17,9 @@ export type ClassifiedFormat = { */ readonly disclosed: Jsonify; }; + +export function isClassifiedFormat( + value: any, +): value is ClassifiedFormat { + return "id" in value && "secret" in value && "disclosed" in value; +} diff --git a/libs/common/src/tools/state/identity-state-constraint.ts b/libs/common/src/tools/state/identity-state-constraint.ts index ff7712b909..df33dad543 100644 --- a/libs/common/src/tools/state/identity-state-constraint.ts +++ b/libs/common/src/tools/state/identity-state-constraint.ts @@ -1,4 +1,11 @@ -import { Constraints, StateConstraints } from "../types"; +import { BehaviorSubject, Observable } from "rxjs"; + +import { + Constraints, + DynamicStateConstraints, + StateConstraints, + SubjectConstraints, +} from "../types"; // The constraints type shares the properties of the state, // but never has any members @@ -9,16 +16,31 @@ const EMPTY_CONSTRAINTS = new Proxy(Object.freeze({}), { }); /** A constraint that does nothing. */ -export class IdentityConstraint implements StateConstraints { +export class IdentityConstraint + implements StateConstraints, DynamicStateConstraints +{ /** Instantiate the identity constraint */ constructor() {} readonly constraints: Readonly> = EMPTY_CONSTRAINTS; + calibrate() { + return this; + } + adjust(state: State) { return state; } + fix(state: State) { return state; } } + +/** Emits a constraint that does not alter the input state. */ +export function unconstrained$(): Observable> { + const identity = new IdentityConstraint(); + const constraints$ = new BehaviorSubject(identity); + + return constraints$; +} diff --git a/libs/common/src/tools/state/object-key.ts b/libs/common/src/tools/state/object-key.ts new file mode 100644 index 0000000000..88365d5cbd --- /dev/null +++ b/libs/common/src/tools/state/object-key.ts @@ -0,0 +1,53 @@ +import { UserKeyDefinition, UserKeyDefinitionOptions } from "../../platform/state"; +// eslint-disable-next-line -- `StateDefinition` used as a type +import type { StateDefinition } from "../../platform/state/state-definition"; + +import { ClassifiedFormat } from "./classified-format"; +import { Classifier } from "./classifier"; + +/** A key for storing JavaScript objects (`{ an: "example" }`) + * in a UserStateSubject. + */ +// FIXME: promote to class: `ObjectConfiguration`. +// The class receives `encryptor`, `prepareNext`, `adjust`, and `fix` +// From `UserStateSubject`. `UserStateSubject` keeps `classify` and +// `declassify`. The class should also include serialization +// facilities (to be used in place of JSON.parse/stringify) in it's +// options. Also allow swap between "classifier" and "classification"; the +// latter is a list of properties/arguments to the specific classifier in-use. +export type ObjectKey> = { + target: "object"; + key: string; + state: StateDefinition; + classifier: Classifier; + format: "plain" | "classified"; + options: UserKeyDefinitionOptions; +}; + +export function isObjectKey(key: any): key is ObjectKey { + return key.target === "object" && "format" in key && "classifier" in key; +} + +export function toUserKeyDefinition( + key: ObjectKey, +) { + if (key.format === "plain") { + const plain = new UserKeyDefinition(key.state, key.key, key.options); + + return plain; + } else if (key.format === "classified") { + const classified = new UserKeyDefinition>( + 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/state-constraints-dependency.ts b/libs/common/src/tools/state/state-constraints-dependency.ts index 66bac636bd..427ff42e7a 100644 --- a/libs/common/src/tools/state/state-constraints-dependency.ts +++ b/libs/common/src/tools/state/state-constraints-dependency.ts @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -import { DynamicStateConstraints, StateConstraints } from "../types"; +import { DynamicStateConstraints, StateConstraints, SubjectConstraints } from "../types"; /** A pattern for types that depend upon a dynamic set of constraints. * @@ -10,12 +10,12 @@ import { DynamicStateConstraints, StateConstraints } from "../types"; * last-emitted constraints. If `constraints$` completes, the consumer should * continue using the last-emitted constraints. */ -export type StateConstraintsDependency = { +export type SubjectConstraintsDependency = { /** A stream that emits constraints when subscribed and when the * constraints change. The stream should not emit `null` or * `undefined`. */ - constraints$: Observable | DynamicStateConstraints>; + constraints$: Observable>; }; /** Returns `true` if the input constraint is a `DynamicStateConstraints`. diff --git a/libs/common/src/tools/state/user-state-subject-dependencies.ts b/libs/common/src/tools/state/user-state-subject-dependencies.ts index 7f36ab7cae..0ba842334b 100644 --- a/libs/common/src/tools/state/user-state-subject-dependencies.ts +++ b/libs/common/src/tools/state/user-state-subject-dependencies.ts @@ -1,15 +1,23 @@ -import { Simplify } from "type-fest"; +import { RequireExactlyOne, Simplify } from "type-fest"; -import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies"; +import { + Dependencies, + SingleUserDependency, + SingleUserEncryptorDependency, + WhenDependency, +} from "../dependencies"; -import { StateConstraintsDependency } from "./state-constraints-dependency"; +import { SubjectConstraintsDependency } from "./state-constraints-dependency"; /** dependencies accepted by the user state subject */ export type UserStateSubjectDependencies = Simplify< - SingleUserDependency & + RequireExactlyOne< + SingleUserDependency & SingleUserEncryptorDependency, + "singleUserEncryptor$" | "singleUserId$" + > & Partial & Partial> & - Partial> & { + Partial> & { /** Compute the next stored value. If this is not set, values * provided to `next` unconditionally override state. * @param current the value stored in state 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 73971da4ef..9f5475df9d 100644 --- a/libs/common/src/tools/state/user-state-subject.spec.ts +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -1,14 +1,50 @@ import { BehaviorSubject, of, Subject } from "rxjs"; +import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec"; +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; type TestType = { foo: string }; +const SomeKey = new UserKeyDefinition(GENERATOR_DISK, "TestKey", { + deserializer: (d) => d as TestType, + clearOn: [], +}); + +const SomeObjectKey = { + target: "object", + key: "TestObjectKey", + state: GENERATOR_DISK, + classifier: new PrivateClassifier(), + format: "classified", + options: { + deserializer: (d) => d as TestType, + clearOn: ["logout"], + }, +} satisfies ObjectKey; + +const SomeEncryptor: UserEncryptor = { + userId: SomeUser, + + encrypt(secret) { + const tmp: any = secret; + return Promise.resolve({ foo: `encrypt(${tmp.foo})` } as any); + }, + + decrypt(secret) { + const tmp: any = JSON.parse(secret.encryptedString); + return Promise.resolve({ foo: `decrypt(${tmp.foo})` } as any); + }, +}; function fooMaxLength(maxLength: number): StateConstraints { return Object.freeze({ @@ -43,7 +79,11 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { + singleUserId$, + nextValue, + when$, + }); // the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously subject.next({ foo: "next" }); @@ -65,7 +105,11 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { + singleUserId$, + nextValue, + when$, + }); // the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously subject.next({ foo: "next" }); @@ -79,11 +123,35 @@ describe("UserStateSubject", () => { expect(nextValue).toHaveBeenCalledTimes(1); }); + it("ignores repeated singleUserEncryptor$ emissions", async () => { + // this test looks for `nextValue` because a subscription isn't necessary for + // the subject to update + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const nextValue = jest.fn((_, next) => next); + const singleUserEncryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor: null }); + const subject = new UserStateSubject(SomeKey, () => state, { + nextValue, + singleUserEncryptor$, + }); + + // the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously + subject.next({ foo: "next" }); + await awaitAsync(); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: null }); + await awaitAsync(); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: null }); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: null }); + await awaitAsync(); + + expect(nextValue).toHaveBeenCalledTimes(1); + }); + it("waits for constraints$", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new Subject>(); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); constraints$.next(fooMaxLength(3)); @@ -91,13 +159,28 @@ describe("UserStateSubject", () => { expect(initResult).toEqual({ foo: "ini" }); }); + + it("waits for singleUserEncryptor$", async () => { + const state = new FakeSingleUserState>>( + SomeUser, + { id: null, secret: '{"foo":"init"}', disclosed: {} }, + ); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ }); + const tracker = new ObservableTracker(subject); + + singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor }); + const [initResult] = await tracker.pauseUntilReceived(1); + + expect(initResult).toEqual({ foo: "decrypt(init)" }); + }); }); describe("next", () => { it("emits the next value", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const expected: TestType = { foo: "next" }; let actual: TestType = null; @@ -114,7 +197,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let actual: TestType = null; subject.subscribe((value) => { @@ -132,7 +215,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const shouldUpdate = jest.fn(() => true); - const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate }); const nextVal: TestType = { foo: "next" }; subject.next(nextVal); @@ -147,7 +230,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const shouldUpdate = jest.fn(() => true); const dependencyValue = { bar: "dependency" }; - const subject = new UserStateSubject(state, { + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate, dependencies$: of(dependencyValue), @@ -165,7 +248,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const shouldUpdate = jest.fn(() => true); - const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate }); const expected: TestType = { foo: "next" }; let actual: TestType = null; @@ -183,7 +266,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const shouldUpdate = jest.fn(() => false); - const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate }); subject.next({ foo: "next" }); await awaitAsync(); @@ -200,7 +283,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); - const subject = new UserStateSubject(state, { singleUserId$, nextValue }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, nextValue }); const nextVal: TestType = { foo: "next" }; subject.next(nextVal); @@ -215,7 +298,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const dependencyValue = { bar: "dependency" }; - const subject = new UserStateSubject(state, { + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, nextValue, dependencies$: of(dependencyValue), @@ -236,7 +319,11 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { + singleUserId$, + nextValue, + when$, + }); const nextVal: TestType = { foo: "next" }; subject.next(nextVal); @@ -253,7 +340,11 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const nextValue = jest.fn((_, next) => next); const when$ = new BehaviorSubject(false); - const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { + singleUserId$, + nextValue, + when$, + }); const nextVal: TestType = { foo: "next" }; subject.next(nextVal); @@ -265,42 +356,52 @@ describe("UserStateSubject", () => { expect(nextValue).toHaveBeenCalled(); }); - it("waits to evaluate nextValue until singleUserId$ emits", async () => { - // this test looks for `nextValue` because a subscription isn't necessary for + it("waits to evaluate `UserState.update` until singleUserId$ emits", async () => { + // this test looks for `nextMock` because a subscription isn't necessary for // the subject to update. const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new Subject(); - const nextValue = jest.fn((_, next) => next); - const subject = new UserStateSubject(state, { singleUserId$, nextValue }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); + // precondition: subject doesn't update after `next` const nextVal: TestType = { foo: "next" }; subject.next(nextVal); await awaitAsync(); - expect(nextValue).not.toHaveBeenCalled(); + expect(state.nextMock).not.toHaveBeenCalled(); + singleUserId$.next(SomeUser); await awaitAsync(); - expect(nextValue).toHaveBeenCalled(); + expect(state.nextMock).toHaveBeenCalledWith({ foo: "next" }); }); - it("applies constraints$ on init", async () => { - const state = new FakeSingleUserState(SomeUser, { foo: "init" }); - const singleUserId$ = new BehaviorSubject(SomeUser); - const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); - const tracker = new ObservableTracker(subject); + it("waits to evaluate `UserState.update` until singleUserEncryptor$ emits", async () => { + const state = new FakeSingleUserState>>( + SomeUser, + { id: null, secret: '{"foo":"init"}', disclosed: null }, + ); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ }); - const [result] = await tracker.pauseUntilReceived(1); + // precondition: subject doesn't update after `next` + const nextVal: TestType = { foo: "next" }; + subject.next(nextVal); + await awaitAsync(); + expect(state.nextMock).not.toHaveBeenCalled(); - expect(result).toEqual({ foo: "in" }); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor }); + await awaitAsync(); + + const encrypted = { foo: "encrypt(next)" }; + expect(state.nextMock).toHaveBeenCalledWith({ id: null, secret: encrypted, disclosed: null }); }); it("applies dynamic constraints", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(DynamicFooMaxLength); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); const expected: TestType = { foo: "next" }; const emission = tracker.expectEmission(); @@ -311,24 +412,11 @@ describe("UserStateSubject", () => { expect(actual).toEqual({ foo: "" }); }); - it("applies constraints$ on constraints$ emission", async () => { - const state = new FakeSingleUserState(SomeUser, { foo: "init" }); - const singleUserId$ = new BehaviorSubject(SomeUser); - const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); - const tracker = new ObservableTracker(subject); - - constraints$.next(fooMaxLength(1)); - const [, result] = await tracker.pauseUntilReceived(2); - - expect(result).toEqual({ foo: "i" }); - }); - it("applies constraints$ on next", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); subject.next({ foo: "next" }); @@ -341,7 +429,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); constraints$.next(fooMaxLength(3)); @@ -355,13 +443,17 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new Subject>(); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); - const tracker = new ObservableTracker(subject); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); + const results: any[] = []; + subject.subscribe((r) => { + results.push(r); + }); subject.next({ foo: "next" }); constraints$.next(fooMaxLength(3)); + await awaitAsync(); // `init` is also waiting and is processed before `next` - const [, nextResult] = await tracker.pauseUntilReceived(2); + const [, nextResult] = results; expect(nextResult).toEqual({ foo: "nex" }); }); @@ -370,7 +462,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(3)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); constraints$.error({ some: "error" }); @@ -384,7 +476,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(3)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject); constraints$.complete(); @@ -399,7 +491,7 @@ describe("UserStateSubject", () => { it("emits errors", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const expected: TestType = { foo: "error" }; let actual: TestType = null; @@ -418,7 +510,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let actual: TestType = null; subject.subscribe({ @@ -437,7 +529,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let shouldNotRun = false; subject.subscribe({ @@ -457,7 +549,7 @@ describe("UserStateSubject", () => { it("emits completes", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let actual = false; subject.subscribe({ @@ -475,7 +567,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let shouldNotRun = false; subject.subscribe({ @@ -496,7 +588,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let timesRun = 0; subject.subscribe({ @@ -513,11 +605,36 @@ describe("UserStateSubject", () => { }); describe("subscribe", () => { + it("applies constraints$ on init", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(fooMaxLength(2)); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + + const [result] = await tracker.pauseUntilReceived(1); + + expect(result).toEqual({ foo: "in" }); + }); + + it("applies constraints$ on constraints$ emission", async () => { + const state = new FakeSingleUserState(SomeUser, { foo: "init" }); + const singleUserId$ = new BehaviorSubject(SomeUser); + const constraints$ = new BehaviorSubject(fooMaxLength(2)); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); + const tracker = new ObservableTracker(subject); + + constraints$.next(fooMaxLength(1)); + const [, result] = await tracker.pauseUntilReceived(2); + + expect(result).toEqual({ foo: "i" }); + }); + it("completes when singleUserId$ completes", async () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); let actual = false; subject.subscribe({ @@ -531,12 +648,32 @@ describe("UserStateSubject", () => { expect(actual).toBeTruthy(); }); + it("completes when singleUserId$ completes", async () => { + const state = new FakeSingleUserState>>( + SomeUser, + { id: null, secret: '{"foo":"init"}', disclosed: null }, + ); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ }); + + let actual = false; + subject.subscribe({ + complete: () => { + actual = true; + }, + }); + singleUserEncryptor$.complete(); + await awaitAsync(); + + expect(actual).toBeTruthy(); + }); + it("completes when when$ completes", async () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ }); let actual = false; subject.subscribe({ @@ -557,7 +694,7 @@ describe("UserStateSubject", () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const errorUserId = "error" as UserId; let error = false; @@ -572,11 +709,32 @@ describe("UserStateSubject", () => { expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId }); }); + it("errors when singleUserEncryptor$ changes", async () => { + const state = new FakeSingleUserState>>( + SomeUser, + { id: null, secret: '{"foo":"init"}', disclosed: null }, + ); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ }); + const errorUserId = "error" as UserId; + + let error = false; + subject.subscribe({ + error: (e: unknown) => { + error = e as any; + }, + }); + singleUserEncryptor$.next({ userId: errorUserId, encryptor: SomeEncryptor }); + await awaitAsync(); + + expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId }); + }); + it("errors when singleUserId$ errors", async () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const expected = { error: "description" }; let actual = false; @@ -591,12 +749,31 @@ describe("UserStateSubject", () => { expect(actual).toEqual(expected); }); + it("errors when singleUserEncryptor$ errors", async () => { + const initialValue: TestType = { foo: "init" }; + const state = new FakeSingleUserState(SomeUser, initialValue); + const singleUserEncryptor$ = new Subject>(); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserEncryptor$ }); + const expected = { error: "description" }; + + let actual = false; + subject.subscribe({ + error: (e: unknown) => { + actual = e as any; + }, + }); + singleUserEncryptor$.error(expected); + await awaitAsync(); + + expect(actual).toEqual(expected); + }); + it("errors when when$ errors", async () => { const initialValue: TestType = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialValue); const singleUserId$ = new BehaviorSubject(SomeUser); const when$ = new BehaviorSubject(true); - const subject = new UserStateSubject(state, { singleUserId$, when$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ }); const expected = { error: "description" }; let actual = false; @@ -616,7 +793,7 @@ describe("UserStateSubject", () => { it("returns the userId to which the subject is bound", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new Subject(); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); expect(subject.userId).toEqual(SomeUser); }); @@ -626,7 +803,7 @@ describe("UserStateSubject", () => { it("emits the next value with an empty constraint", async () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected: TestType = { foo: "next" }; const emission = tracker.expectEmission(); @@ -642,7 +819,7 @@ describe("UserStateSubject", () => { const initialState = { foo: "init" }; const state = new FakeSingleUserState(SomeUser, initialState); const singleUserId$ = new BehaviorSubject(SomeUser); - const subject = new UserStateSubject(state, { singleUserId$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ }); const tracker = new ObservableTracker(subject.withConstraints$); subject.complete(); @@ -657,7 +834,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected = fooMaxLength(1); const emission = tracker.expectEmission(); @@ -673,7 +850,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(DynamicFooMaxLength); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected: TestType = { foo: "next" }; const emission = tracker.expectEmission(); @@ -690,7 +867,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const expected = fooMaxLength(2); const constraints$ = new BehaviorSubject(expected); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const emission = tracker.expectEmission(); @@ -705,7 +882,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new BehaviorSubject(fooMaxLength(2)); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected = fooMaxLength(3); constraints$.next(expected); @@ -722,7 +899,7 @@ describe("UserStateSubject", () => { const state = new FakeSingleUserState(SomeUser, { foo: "init" }); const singleUserId$ = new BehaviorSubject(SomeUser); const constraints$ = new Subject>(); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); const expected = fooMaxLength(3); @@ -740,7 +917,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const expected = fooMaxLength(3); const constraints$ = new BehaviorSubject(expected); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); constraints$.error({ some: "error" }); @@ -756,7 +933,7 @@ describe("UserStateSubject", () => { const singleUserId$ = new BehaviorSubject(SomeUser); const expected = fooMaxLength(3); const constraints$ = new BehaviorSubject(expected); - const subject = new UserStateSubject(state, { singleUserId$, constraints$ }); + const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ }); const tracker = new ObservableTracker(subject.withConstraints$); constraints$.complete(); diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 61a9e87c68..89f19ac3c7 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -5,15 +5,10 @@ import { ReplaySubject, filter, map, - Subject, takeUntil, pairwise, - combineLatest, distinctUntilChanged, BehaviorSubject, - race, - ignoreElements, - endWith, startWith, Observable, Subscription, @@ -22,16 +17,32 @@ import { combineLatestWith, catchError, EMPTY, + concatMap, + OperatorFunction, + pipe, + first, + withLatestFrom, + scan, + skip, } from "rxjs"; -import { SingleUserState } from "@bitwarden/common/platform/state"; +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 { WithConstraints } from "../types"; +import { UserBound } from "../dependencies"; +import { anyComplete, ready, withLatestReady } from "../rx"; +import { Constraints, SubjectConstraints, WithConstraints } from "../types"; -import { IdentityConstraint } from "./identity-state-constraint"; +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 }; + /** * Adapt a state provider to an rxjs subject. * @@ -44,14 +55,20 @@ import { UserStateSubjectDependencies } from "./user-state-subject-dependencies" * @template State the state stored by the subject * @template Dependencies use-specific dependencies provided by the user. */ -export class UserStateSubject +export class UserStateSubject< + State extends object, + Secret = State, + Disclosed = never, + Dependencies = null, + > extends Observable implements SubjectLike { /** - * Instantiates the user state subject - * @param state the backing store of the subject - * @param dependencies tailor the subject's behavior for a particular + * Instantiates the user state subject bound to a persistent backing store + * @param key identifies the persistent backing store + * @param getState creates a persistent backing store using a key + * @param context tailor the subject's behavior for a particular * purpose. * @param dependencies.when$ blocks updates to the state subject until * this becomes true. When this occurs, only the last-received update @@ -61,93 +78,306 @@ export class UserStateSubject * is available. */ constructor( - private state: SingleUserState, - private dependencies: UserStateSubjectDependencies, + private key: UserKeyDefinition | ObjectKey, + getState: (key: UserKeyDefinition) => SingleUserState, + private context: UserStateSubjectDependencies, ) { super(); + if (isObjectKey(this.key)) { + // classification and encryption only supported with `ObjectKey` + this.objectKey = this.key; + this.stateKey = toUserKeyDefinition(this.key); + this.state = getState(this.stateKey); + } else { + // raw state access granted with `UserKeyDefinition` + this.objectKey = null; + this.stateKey = this.key as UserKeyDefinition; + this.state = getState(this.stateKey); + } + // normalize dependencies - const when$ = (this.dependencies.when$ ?? new BehaviorSubject(true)).pipe( - distinctUntilChanged(), - ); - const userIdAvailable$ = this.dependencies.singleUserId$.pipe( - startWith(state.userId), - pairwise(), - map(([expectedUserId, actualUserId]) => { - if (expectedUserId === actualUserId) { - return true; - } else { - throw { expectedUserId, actualUserId }; - } - }), - distinctUntilChanged(), - ); - const constraints$ = ( - this.dependencies.constraints$ ?? new BehaviorSubject(new IdentityConstraint()) - ).pipe( - // FIXME: this should probably log that an error occurred - catchError(() => EMPTY), - ); + const when$ = (this.context.when$ ?? new BehaviorSubject(true)).pipe(distinctUntilChanged()); - // normalize input in case this `UserStateSubject` is not the only - // observer of the backing store - const input$ = combineLatest([this.input, constraints$]).pipe( - map(([input, constraints]) => { - const calibration = isDynamic(constraints) ? constraints.calibrate(input) : constraints; - const state = calibration.adjust(input); - return state; - }), - ); + // manage dependencies through replay subjects since `UserStateSubject` + // reads them in multiple places + const encryptor$ = new ReplaySubject(1); + const { singleUserId$, singleUserEncryptor$ } = this.context; + this.encryptor(singleUserEncryptor$ ?? singleUserId$).subscribe(encryptor$); - // when the output subscription completes, its last-emitted value - // loops around to the input for finalization - const finalize$ = this.pipe( - last(), - combineLatestWith(constraints$), - map(([output, constraints]) => { - const calibration = isDynamic(constraints) ? constraints.calibrate(output) : constraints; - const state = calibration.fix(output); - return state; - }), - ); - const updates$ = concat(input$, finalize$); + const constraints$ = new ReplaySubject>(1); + (this.context.constraints$ ?? unconstrained$()) + .pipe( + // FIXME: this should probably log that an error occurred + catchError(() => EMPTY), + ) + .subscribe(constraints$); - // observe completion - const whenComplete$ = when$.pipe(ignoreElements(), endWith(true)); - const inputComplete$ = this.input.pipe(ignoreElements(), endWith(true)); - const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true)); - const completion$ = race(whenComplete$, inputComplete$, userIdComplete$); + const dependencies$ = new ReplaySubject(1); + if (this.context.dependencies$) { + this.context.dependencies$.subscribe(dependencies$); + } else { + dependencies$.next(null); + } // wire output before input so that output normalizes the current state // before any `next` value is processed this.outputSubscription = this.state.state$ - .pipe( - combineLatestWith(constraints$), - map(([rawState, constraints]) => { - const calibration = isDynamic(constraints) - ? constraints.calibrate(rawState) - : constraints; - const state = calibration.adjust(rawState); - return { - constraints: calibration.constraints, - state, - }; - }), - ) + .pipe(this.declassify(encryptor$), this.adjust(combineLatestWith(constraints$))) .subscribe(this.output); - this.inputSubscription = combineLatest([updates$, when$, userIdAvailable$]) + + const last$ = new ReplaySubject(1); + this.output .pipe( - filter(([_, when]) => when), - map(([state]) => state), - takeUntil(completion$), + last(), + map((o) => o.state), ) + .subscribe(last$); + + // the update stream simulates the stateProvider's "shouldUpdate" + // functionality & applies policy + const updates$ = concat( + this.input.pipe( + this.when(when$), + this.adjust(withLatestReady(constraints$)), + this.prepareUpdate(this, dependencies$), + ), + // when the output subscription completes, its last-emitted value + // loops around to the input for finalization + last$.pipe(this.fix(constraints$), this.prepareUpdate(last$, dependencies$)), + ); + + // classification/encryption bound to the input subscription's lifetime + // to ensure that `fix` has access to the encryptor key + // + // FIXME: this should probably timeout when a lock occurs + this.inputSubscription = updates$ + .pipe(this.classify(encryptor$), takeUntil(anyComplete([when$, this.input, encryptor$]))) .subscribe({ - next: (r) => this.onNext(r), + next: (state) => this.onNext(state), error: (e: unknown) => this.onError(e), complete: () => this.onComplete(), }); } + private stateKey: UserKeyDefinition; + private objectKey: ObjectKey; + + private encryptor( + singleUserEncryptor$: Observable | UserId>, + ): Observable { + return singleUserEncryptor$.pipe( + // normalize inputs + map((maybe): UserBound<"encryptor", UserEncryptor> => { + if (typeof maybe === "object" && "encryptor" in maybe) { + return maybe; + } else if (typeof maybe === "string") { + return { encryptor: null, userId: maybe as UserId }; + } else { + throw new Error(`Invalid encryptor input received for ${this.key.key}.`); + } + }), + // 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, + }; + } + }), + // reduce emissions to when encryptor changes + distinctUntilChanged(), + map(({ encryptor }) => encryptor), + ); + } + + private when(when$: Observable): OperatorFunction { + return pipe( + combineLatestWith(when$.pipe(distinctUntilChanged())), + filter(([_, when]) => !!when), + map(([input]) => input), + ); + } + + private prepareUpdate( + init$: Observable, + dependencies$: Observable, + ): OperatorFunction, State> { + return (input$) => + concat( + // `init$` becomes the accumulator for `scan` + init$.pipe( + first(), + map((init) => [init, null] as const), + ), + input$.pipe( + map((constrained) => constrained.state), + withLatestFrom(dependencies$), + ), + ).pipe( + // scan only emits values that can cause updates + scan(([prev], [pending, dependencies]) => { + const shouldUpdate = this.context.shouldUpdate?.(prev, pending, dependencies) ?? true; + if (shouldUpdate) { + // actual update + const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending; + return [next, dependencies]; + } else { + // false update + return [prev, null]; + } + }), + // the first emission primes `scan`s aggregator + skip(1), + map(([state]) => state), + + // clean up false updates + distinctUntilChanged(), + ); + } + + private adjust( + withConstraints: OperatorFunction]>, + ): OperatorFunction> { + return pipe( + // how constraints are blended with incoming emissions varies: + // * `output` needs to emit when constraints update + // * `input` needs to wait until a message flows through the pipe + withConstraints, + map(([loadedState, constraints]) => { + // bypass nulls + if (!loadedState) { + return { + constraints: {} as Constraints, + state: null, + } satisfies Constrained; + } + + const calibration = isDynamic(constraints) + ? constraints.calibrate(loadedState) + : constraints; + const adjusted = calibration.adjust(loadedState); + + return { + constraints: calibration.constraints, + state: adjusted, + }; + }), + ); + } + + private fix( + constraints$: Observable>, + ): OperatorFunction> { + return pipe( + combineLatestWith(constraints$), + map(([loadedState, constraints]) => { + const calibration = isDynamic(constraints) + ? constraints.calibrate(loadedState) + : constraints; + const fixed = calibration.fix(loadedState); + + return { + constraints: calibration.constraints, + state: fixed, + }; + }), + ); + } + + private declassify(encryptor$: Observable): OperatorFunction { + // short-circuit if they key lacks encryption support + if (!this.objectKey || this.objectKey.format === "plain") { + return (input$) => input$ as Observable; + } + + // if the key supports encryption, enable encryptor support + 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; + } + + // 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; + }), + ); + } + + throw new Error(`unknown serialization format: ${this.objectKey.format}`); + } + + private classify(encryptor$: Observable): OperatorFunction { + // short-circuit if they key lacks encryption support; `encryptor` is + // readied to preserve `dependencies.singleUserId$` emission contract + if (!this.objectKey || this.objectKey.format === "plain") { + return pipe( + ready(encryptor$), + map((input) => input as unknown), + ); + } + + // if the key supports encryption, enable encryptor support + 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; + }), + ); + } + + // FIXME: add "encrypted" format --> key contains encryption logic + // CONSIDER: should "classified format" algorithm be embedded in subject keys...? + + throw new Error(`unknown serialization format: ${this.objectKey.format}`); + } + /** The userId to which the subject is bound. */ get userId() { @@ -177,7 +407,8 @@ export class UserStateSubject // using subjects to ensure the right semantics are followed; // if greater efficiency becomes desirable, consider implementing // `SubjectLike` directly - private input = new Subject(); + private input = new ReplaySubject(1); + private state: SingleUserState; private readonly output = new ReplaySubject>(1); /** A stream containing settings and their last-applied constraints. */ @@ -188,25 +419,8 @@ export class UserStateSubject private inputSubscription: Unsubscribable; private outputSubscription: Unsubscribable; - private onNext(value: State) { - const nextValue = this.dependencies.nextValue ?? ((_: State, next: State) => next); - const shouldUpdate = this.dependencies.shouldUpdate ?? ((_: State) => true); - - this.state - .update( - (state, dependencies) => { - const next = nextValue(state, value, dependencies); - return next; - }, - { - shouldUpdate(current, dependencies) { - const update = shouldUpdate(current, value, dependencies); - return update; - }, - combineLatestWith: this.dependencies.dependencies$, - }, - ) - .catch((e: any) => this.onError(e)); + private onNext(value: unknown) { + this.state.update(() => value).catch((e: any) => this.onError(e)); } private onError(value: any) { @@ -232,8 +446,8 @@ export class UserStateSubject private dispose() { if (!this.isDisposed) { // clean up internal subscriptions - this.inputSubscription.unsubscribe(); - this.outputSubscription.unsubscribe(); + this.inputSubscription?.unsubscribe(); + this.outputSubscription?.unsubscribe(); this.inputSubscription = null; this.outputSubscription = null; diff --git a/libs/common/src/tools/types.ts b/libs/common/src/tools/types.ts index ec1903e622..9b74692427 100644 --- a/libs/common/src/tools/types.ts +++ b/libs/common/src/tools/types.ts @@ -1,5 +1,7 @@ import { Simplify } from "type-fest"; +import { IntegrationId } from "./integration"; + /** Constraints that are shared by all primitive field types */ type PrimitiveConstraint = { /** `true` indicates the field is required; otherwise the field is optional */ @@ -129,6 +131,8 @@ export type StateConstraints = { fix: (state: State) => State; }; +export type SubjectConstraints = StateConstraints | DynamicStateConstraints; + /** Options that provide contextual information about the application state * when a generator is invoked. */ @@ -144,4 +148,7 @@ export type VaultItemRequest = { /** Options that provide contextual information about the application state * when a generator is invoked. */ -export type GenerationRequest = Partial; +export type GenerationRequest = Partial & + Partial<{ + integration: IntegrationId | null; + }>; diff --git a/libs/tools/generator/components/src/credential-generator.component.html b/libs/tools/generator/components/src/credential-generator.component.html index 53df58c848..4c9fb9e7e4 100644 --- a/libs/tools/generator/components/src/credential-generator.component.html +++ b/libs/tools/generator/components/src/credential-generator.component.html @@ -3,7 +3,7 @@ fullWidth class="tw-mb-4" [selected]="(root$ | async).nav" - (selectedChange)="onRootChanged($event)" + (selectedChange)="onRootChanged({ nav: $event })" attr.aria-label="{{ 'type' | i18n }}" > @@ -35,23 +35,23 @@ -
{{ "options" | i18n }}
+

{{ "options" | i18n }}

-
+ {{ "type" | i18n }} @@ -60,18 +60,29 @@ }}
+
+ + {{ "service" | i18n }} + + +
+ diff --git a/libs/tools/generator/components/src/credential-generator.component.ts b/libs/tools/generator/components/src/credential-generator.component.ts index 86093beecd..25aff97f16 100644 --- a/libs/tools/generator/components/src/credential-generator.component.ts +++ b/libs/tools/generator/components/src/credential-generator.component.ts @@ -2,11 +2,12 @@ import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } fro import { FormBuilder } from "@angular/forms"; import { BehaviorSubject, - concat, + catchError, + combineLatest, + combineLatestWith, distinctUntilChanged, filter, map, - of, ReplaySubject, Subject, switchMap, @@ -16,25 +17,32 @@ import { import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { IntegrationId } from "@bitwarden/common/tools/integration"; import { UserId } from "@bitwarden/common/types/guid"; +import { ToastService } from "@bitwarden/components"; import { Option } from "@bitwarden/components/src/select/option"; import { + AlgorithmInfo, CredentialAlgorithm, CredentialCategory, - CredentialGeneratorInfo, CredentialGeneratorService, GeneratedCredential, Generators, + getForwarderConfiguration, isEmailAlgorithm, + isForwarderIntegration, isPasswordAlgorithm, + isSameAlgorithm, isUsernameAlgorithm, - PasswordAlgorithm, + toCredentialGeneratorConfiguration, } from "@bitwarden/generator-core"; -/** root category that drills into username and email categories */ +// constants used to identify navigation selections that are not +// generator algorithms const IDENTIFIER = "identifier"; -/** options available for the top-level navigation */ -type RootNavValue = PasswordAlgorithm | typeof IDENTIFIER; +const FORWARDER = "forwarder"; +const NONE_SELECTED = "none"; @Component({ selector: "tools-credential-generator", @@ -43,6 +51,8 @@ type RootNavValue = PasswordAlgorithm | typeof IDENTIFIER; export class CredentialGeneratorComponent implements OnInit, OnDestroy { constructor( private generatorService: CredentialGeneratorService, + private toastService: ToastService, + private logService: LogService, private i18nService: I18nService, private accountService: AccountService, private zone: NgZone, @@ -59,59 +69,25 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { @Output() readonly onGenerated = new EventEmitter(); - protected root$ = new BehaviorSubject<{ nav: RootNavValue }>({ + protected root$ = new BehaviorSubject<{ nav: string }>({ nav: null, }); - /** - * Emits the copy button aria-label respective of the selected credential type - * - * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. - */ - protected credentialTypeCopyLabel$ = this.root$.pipe( - map(({ nav }) => { - if (nav === "password") { - return this.i18nService.t("copyPassword"); - } - - if (nav === "passphrase") { - return this.i18nService.t("copyPassphrase"); - } - - return this.i18nService.t("copyUsername"); - }), - ); - - /** - * Emits the generate button aria-label respective of the selected credential type - * - * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. - */ - protected credentialTypeGenerateLabel$ = this.root$.pipe( - map(({ nav }) => { - if (nav === "password") { - return this.i18nService.t("generatePassword"); - } - - if (nav === "passphrase") { - return this.i18nService.t("generatePassphrase"); - } - - return this.i18nService.t("generateUsername"); - }), - ); - - protected onRootChanged(nav: RootNavValue) { + protected onRootChanged(value: { nav: string }) { // prevent subscription cycle - if (this.root$.value.nav !== nav) { + if (this.root$.value.nav !== value.nav) { this.zone.run(() => { - this.root$.next({ nav }); + this.root$.next(value); }); } } protected username = this.formBuilder.group({ - nav: [null as CredentialAlgorithm], + nav: [null as string], + }); + + protected forwarder = this.formBuilder.group({ + nav: [null as string], }); async ngOnInit() { @@ -130,16 +106,29 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { this.generatorService .algorithms$(["email", "username"], { userId$: this.userId$ }) .pipe( - map((algorithms) => this.toOptions(algorithms)), + map((algorithms) => { + const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id)); + const usernameOptions = this.toOptions(usernames); + usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwardedEmail") }); + + const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id)); + const forwarderOptions = this.toOptions(forwarders); + forwarderOptions.unshift({ value: NONE_SELECTED, label: this.i18nService.t("select") }); + + return [usernameOptions, forwarderOptions] as const; + }), takeUntil(this.destroyed), ) - .subscribe(this.usernameOptions$); + .subscribe(([usernames, forwarders]) => { + this.usernameOptions$.next(usernames); + this.forwarderOptions$.next(forwarders); + }); this.generatorService .algorithms$("password", { userId$: this.userId$ }) .pipe( map((algorithms) => { - const options = this.toOptions(algorithms) as Option[]; + const options = this.toOptions(algorithms); options.push({ value: IDENTIFIER, label: this.i18nService.t("username") }); return options; }), @@ -149,7 +138,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { this.algorithm$ .pipe( - map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)), + map((a) => a?.description), takeUntil(this.destroyed), ) .subscribe((hint) => { @@ -162,7 +151,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { this.algorithm$ .pipe( - map((a) => a.category), + map((a) => a?.category), distinctUntilChanged(), takeUntil(this.destroyed), ) @@ -177,7 +166,22 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { // wire up the generator this.algorithm$ .pipe( + filter((algorithm) => !!algorithm), switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), + catchError((error: unknown, generator) => { + if (typeof error === "string") { + this.toastService.showToast({ + message: error, + variant: "error", + title: "", + }); + } else { + this.logService.error(error); + } + + // continue with origin stream + return generator; + }), takeUntil(this.destroyed), ) .subscribe((generated) => { @@ -189,35 +193,116 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { }); }); - // assume the last-visible generator algorithm is the user's preferred one - const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); + // normalize cascade selections; introduce subjects to allow changes + // from user selections and changes from preference updates to + // update the template + type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm }; + const activeRoot$ = new Subject(); + const activeIdentifier$ = new Subject(); + const activeForwarder$ = new Subject(); + this.root$ .pipe( - filter(({ nav }) => !!nav), - switchMap((root) => { - if (root.nav === IDENTIFIER) { - return concat(of(this.username.value), this.username.valueChanges); + map( + (root): CascadeValue => + root.nav === IDENTIFIER + ? { nav: root.nav } + : { nav: root.nav, algorithm: JSON.parse(root.nav) }, + ), + takeUntil(this.destroyed), + ) + .subscribe(activeRoot$); + + this.username.valueChanges + .pipe( + map( + (username): CascadeValue => + username.nav === FORWARDER + ? { nav: username.nav } + : { nav: username.nav, algorithm: JSON.parse(username.nav) }, + ), + takeUntil(this.destroyed), + ) + .subscribe(activeIdentifier$); + + this.forwarder.valueChanges + .pipe( + map( + (forwarder): CascadeValue => + forwarder.nav === NONE_SELECTED + ? { nav: forwarder.nav } + : { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) }, + ), + takeUntil(this.destroyed), + ) + .subscribe(activeForwarder$); + + // update forwarder cascade visibility + combineLatest([activeRoot$, activeIdentifier$, activeForwarder$]) + .pipe( + map(([root, username, forwarder]) => { + const showForwarder = !root.algorithm && !username.algorithm; + const forwarderId = + showForwarder && isForwarderIntegration(forwarder.algorithm) + ? forwarder.algorithm.forwarder + : null; + return [showForwarder, forwarderId] as const; + }), + distinctUntilChanged((prev, next) => prev[0] === next[0] && prev[1] === next[1]), + takeUntil(this.destroyed), + ) + .subscribe(([showForwarder, forwarderId]) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.showForwarder$.next(showForwarder); + this.forwarderId$.next(forwarderId); + }); + }); + + // update active algorithm + combineLatest([activeRoot$, activeIdentifier$, activeForwarder$]) + .pipe( + map(([root, username, forwarder]) => { + const selection = root.algorithm ?? username.algorithm ?? forwarder.algorithm; + if (selection) { + return this.generatorService.algorithm(selection); } else { - return of(root as { nav: PasswordAlgorithm }); + return null; } }), - filter(({ nav }) => !!nav), + distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)), + takeUntil(this.destroyed), + ) + .subscribe((algorithm) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.algorithm$.next(algorithm); + }); + }); + + // assume the last-selected generator algorithm is the user's preferred one + const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); + this.algorithm$ + .pipe( + filter((algorithm) => !!algorithm), withLatestFrom(preferences), takeUntil(this.destroyed), ) - .subscribe(([{ nav: algorithm }, preference]) => { + .subscribe(([algorithm, preference]) => { function setPreference(category: CredentialCategory) { const p = preference[category]; - p.algorithm = algorithm; + p.algorithm = algorithm.id; p.updated = new Date(); } // `is*Algorithm` decides `algorithm`'s type, which flows into `setPreference` - if (isEmailAlgorithm(algorithm)) { + if (isEmailAlgorithm(algorithm.id)) { setPreference("email"); - } else if (isUsernameAlgorithm(algorithm)) { + } else if (isUsernameAlgorithm(algorithm.id)) { setPreference("username"); - } else if (isPasswordAlgorithm(algorithm)) { + } else if (isPasswordAlgorithm(algorithm.id)) { setPreference("password"); } else { return; @@ -227,34 +312,74 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { }); // populate the form with the user's preferences to kick off interactivity - preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username, password }) => { - // the last preference set by the user "wins" - const userNav = email.updated > username.updated ? email : username; - const rootNav: any = userNav.updated > password.updated ? IDENTIFIER : password.algorithm; - const credentialType = rootNav === IDENTIFIER ? userNav.algorithm : password.algorithm; - - // update navigation; break subscription loop - this.onRootChanged(rootNav); - this.username.setValue({ nav: userNav.algorithm }, { emitEvent: false }); - - // load algorithm metadata - const algorithm = this.generatorService.algorithm(credentialType); - - // update subjects within the angular zone so that the - // template bindings refresh immediately - this.zone.run(() => { - this.algorithm$.next(algorithm); - }); - }); - - // generate on load unless the generator prohibits it - this.algorithm$ + preferences .pipe( - distinctUntilChanged((prev, next) => prev.id === next.id), - filter((a) => !a.onlyOnRequest), + map(({ email, username, password }) => { + const forwarderPref = isForwarderIntegration(email.algorithm) ? email : null; + const usernamePref = email.updated > username.updated ? email : username; + + // inject drilldown flags + const forwarderNav = !forwarderPref + ? NONE_SELECTED + : JSON.stringify(forwarderPref.algorithm); + const userNav = forwarderPref ? FORWARDER : JSON.stringify(usernamePref.algorithm); + const rootNav = + usernamePref.updated > password.updated + ? IDENTIFIER + : JSON.stringify(password.algorithm); + + // construct cascade metadata + const cascade = { + root: { + selection: { nav: rootNav }, + active: { + nav: rootNav, + algorithm: rootNav === IDENTIFIER ? null : password.algorithm, + } as CascadeValue, + }, + username: { + selection: { nav: userNav }, + active: { + nav: userNav, + algorithm: forwarderPref ? null : usernamePref.algorithm, + }, + }, + forwarder: { + selection: { nav: forwarderNav }, + active: { + nav: forwarderNav, + algorithm: forwarderPref?.algorithm, + }, + }, + }; + + return cascade; + }), takeUntil(this.destroyed), ) - .subscribe(() => this.generate$.next()); + .subscribe(({ root, username, forwarder }) => { + // update navigation; break subscription loop + this.onRootChanged(root.selection); + this.username.setValue(username.selection, { emitEvent: false }); + this.forwarder.setValue(forwarder.selection, { emitEvent: false }); + + // update cascade visibility + activeRoot$.next(root.active); + activeIdentifier$.next(username.active); + activeForwarder$.next(forwarder.active); + }); + + // automatically regenerate when the algorithm switches if the algorithm + // allows it; otherwise set a placeholder + this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { + this.zone.run(() => { + if (!a || a.onlyOnRequest) { + this.value$.next("-"); + } else { + this.generate$.next(); + } + }); + }); } private typeToGenerator$(type: CredentialAlgorithm) { @@ -278,20 +403,61 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { case "passphrase": return this.generatorService.generate$(Generators.passphrase, dependencies); - - default: - throw new Error(`Invalid generator type: "${type}"`); } + + if (isForwarderIntegration(type)) { + const forwarder = getForwarderConfiguration(type.forwarder); + const configuration = toCredentialGeneratorConfiguration(forwarder); + const generator = this.generatorService.generate$(configuration, dependencies); + return generator; + } + + throw new Error(`Invalid generator type: "${type}"`); } - /** Lists the credential types of the username algorithm box. */ - protected usernameOptions$ = new BehaviorSubject[]>([]); + /** Lists the top-level credential types supported by the component. + * @remarks This is string-typed because angular doesn't support + * structural equality for objects, which prevents `CredentialAlgorithm` + * from being selectable within a dropdown when its value contains a + * `ForwarderIntegration`. + */ + protected rootOptions$ = new BehaviorSubject[]>([]); - /** Lists the top-level credential types supported by the component. */ - protected rootOptions$ = new BehaviorSubject[]>([]); + /** Lists the credential types of the username algorithm box. */ + protected usernameOptions$ = new BehaviorSubject[]>([]); + + /** Lists the credential types of the username algorithm box. */ + protected forwarderOptions$ = new BehaviorSubject[]>([]); + + /** Tracks the currently selected forwarder. */ + protected forwarderId$ = new BehaviorSubject(null); + + /** Tracks forwarder control visibility */ + protected showForwarder$ = new BehaviorSubject(false); /** tracks the currently selected credential type */ - protected algorithm$ = new ReplaySubject(1); + protected algorithm$ = new ReplaySubject(1); + + protected showAlgorithm$ = this.algorithm$.pipe( + combineLatestWith(this.showForwarder$), + map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)), + ); + + /** + * Emits the copy button aria-label respective of the selected credential type + */ + protected credentialTypeCopyLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); + + /** + * Emits the generate button aria-label respective of the selected credential type + */ + protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); /** Emits hint key for the currently selected credential type */ protected credentialTypeHint$ = new ReplaySubject(1); @@ -308,10 +474,10 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy { /** Emits when a new credential is requested */ protected readonly generate$ = new Subject(); - private toOptions(algorithms: CredentialGeneratorInfo[]) { - const options: Option[] = algorithms.map((algorithm) => ({ - value: algorithm.id, - label: this.i18nService.t(algorithm.nameKey), + private toOptions(algorithms: AlgorithmInfo[]) { + const options: Option[] = algorithms.map((algorithm) => ({ + value: JSON.stringify(algorithm.id), + label: algorithm.name, })); return options; diff --git a/libs/tools/generator/components/src/forwarder-settings.component.html b/libs/tools/generator/components/src/forwarder-settings.component.html new file mode 100644 index 0000000000..64566fa956 --- /dev/null +++ b/libs/tools/generator/components/src/forwarder-settings.component.html @@ -0,0 +1,16 @@ +
+ + {{ "forwarderDomainName" | i18n }} + + {{ "forwarderDomainNameHint" | i18n }} + + + {{ "apiKey" | i18n }} + + + + + {{ "selfHostBaseUrl" | i18n }} + + +
diff --git a/libs/tools/generator/components/src/forwarder-settings.component.ts b/libs/tools/generator/components/src/forwarder-settings.component.ts new file mode 100644 index 0000000000..a1e6c7acfd --- /dev/null +++ b/libs/tools/generator/components/src/forwarder-settings.component.ts @@ -0,0 +1,195 @@ +import { + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, +} from "@angular/core"; +import { FormBuilder } from "@angular/forms"; +import { + BehaviorSubject, + concatMap, + map, + ReplaySubject, + skip, + Subject, + switchAll, + switchMap, + takeUntil, + withLatestFrom, +} from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { IntegrationId } from "@bitwarden/common/tools/integration"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + CredentialGeneratorConfiguration, + CredentialGeneratorService, + getForwarderConfiguration, + NoPolicy, + toCredentialGeneratorConfiguration, +} from "@bitwarden/generator-core"; + +import { completeOnAccountSwitch, toValidators } from "./util"; + +const Controls = Object.freeze({ + domain: "domain", + token: "token", + baseUrl: "baseUrl", +}); + +/** Options group for forwarder integrations */ +@Component({ + selector: "tools-forwarder-settings", + templateUrl: "forwarder-settings.component.html", +}) +export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy { + /** Instantiates the component + * @param accountService queries user availability + * @param generatorService settings and policy logic + * @param formBuilder reactive form controls + */ + constructor( + private formBuilder: FormBuilder, + private generatorService: CredentialGeneratorService, + private accountService: AccountService, + ) {} + + /** Binds the component to a specific user's settings. + * When this input is not provided, the form binds to the active + * user + */ + @Input() + userId: UserId | null; + + @Input({ required: true }) + forwarder: IntegrationId; + + /** Emits settings updates and completes if the settings become unavailable. + * @remarks this does not emit the initial settings. If you would like + * to receive live settings updates including the initial update, + * use `CredentialGeneratorService.settings$(...)` instead. + */ + @Output() + readonly onUpdated = new EventEmitter(); + + /** The template's control bindings */ + protected settings = this.formBuilder.group({ + [Controls.domain]: [""], + [Controls.token]: [""], + [Controls.baseUrl]: [""], + }); + + private forwarderId$ = new ReplaySubject(1); + + async ngOnInit() { + const singleUserId$ = this.singleUserId$(); + + const forwarder$ = new ReplaySubject>(1); + this.forwarderId$ + .pipe( + map((id) => getForwarderConfiguration(id)), + // type erasure necessary because the configuration properties are + // determined dynamically at runtime + // FIXME: this can be eliminated by unifying the forwarder settings types; + // see `ForwarderConfiguration<...>` for details. + map((forwarder) => toCredentialGeneratorConfiguration(forwarder)), + takeUntil(this.destroyed$), + ) + .subscribe((forwarder) => { + this.displayDomain = forwarder.request.includes("domain"); + this.displayToken = forwarder.request.includes("token"); + this.displayBaseUrl = forwarder.request.includes("baseUrl"); + + forwarder$.next(forwarder); + }); + + const settings$$ = forwarder$.pipe( + concatMap((forwarder) => this.generatorService.settings(forwarder, { singleUserId$ })), + ); + + // bind settings to the reactive form + settings$$.pipe(switchAll(), takeUntil(this.destroyed$)).subscribe((settings) => { + // skips reactive event emissions to break a subscription cycle + this.settings.patchValue(settings as any, { emitEvent: false }); + }); + + // bind policy to the reactive form + forwarder$ + .pipe( + switchMap((forwarder) => { + const constraints$ = this.generatorService + .policy$(forwarder, { userId$: singleUserId$ }) + .pipe(map(({ constraints }) => [constraints, forwarder] as const)); + + return constraints$; + }), + takeUntil(this.destroyed$), + ) + .subscribe(([constraints, forwarder]) => { + for (const name in Controls) { + const control = this.settings.get(name); + if (forwarder.request.includes(name as any)) { + control.enable({ emitEvent: false }); + control.setValidators( + // the configuration's type erasure affects `toValidators` as well + toValidators(name, forwarder, constraints), + ); + } else { + control.disable({ emitEvent: false }); + control.clearValidators(); + } + } + }); + + // the first emission is the current value; subsequent emissions are updates + settings$$ + .pipe( + map((settings$) => settings$.pipe(skip(1))), + switchAll(), + takeUntil(this.destroyed$), + ) + .subscribe(this.onUpdated); + + // now that outputs are set up, connect inputs + this.settings.valueChanges + .pipe(withLatestFrom(settings$$), takeUntil(this.destroyed$)) + .subscribe(([value, settings]) => { + settings.next(value); + }); + } + + ngOnChanges(changes: SimpleChanges): void { + this.refresh$.complete(); + if ("forwarder" in changes) { + this.forwarderId$.next(this.forwarder); + } + } + + protected displayDomain: boolean; + protected displayToken: boolean; + protected displayBaseUrl: boolean; + + private singleUserId$() { + // FIXME: this branch should probably scan for the user and make sure + // the account is unlocked + if (this.userId) { + return new BehaviorSubject(this.userId as UserId).asObservable(); + } + + return this.accountService.activeAccount$.pipe( + completeOnAccountSwitch(), + takeUntil(this.destroyed$), + ); + } + + private readonly refresh$ = new Subject(); + + private readonly destroyed$ = new Subject(); + ngOnDestroy(): void { + this.destroyed$.complete(); + } +} diff --git a/libs/tools/generator/components/src/generator.module.ts b/libs/tools/generator/components/src/generator.module.ts index 96622774a3..58117bec49 100644 --- a/libs/tools/generator/components/src/generator.module.ts +++ b/libs/tools/generator/components/src/generator.module.ts @@ -5,8 +5,11 @@ import { ReactiveFormsModule } from "@angular/forms"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CardComponent, @@ -30,6 +33,7 @@ import { import { CatchallSettingsComponent } from "./catchall-settings.component"; import { CredentialGeneratorComponent } from "./credential-generator.component"; +import { ForwarderSettingsComponent } from "./forwarder-settings.component"; import { PassphraseSettingsComponent } from "./passphrase-settings.component"; import { PasswordGeneratorComponent } from "./password-generator.component"; import { PasswordSettingsComponent } from "./password-settings.component"; @@ -67,18 +71,27 @@ const RANDOMIZER = new SafeInjectionToken("Randomizer"); safeProvider({ provide: CredentialGeneratorService, useClass: CredentialGeneratorService, - deps: [RANDOMIZER, StateProvider, PolicyService], + deps: [ + RANDOMIZER, + StateProvider, + PolicyService, + ApiService, + I18nService, + EncryptService, + CryptoService, + ], }), ], declarations: [ CatchallSettingsComponent, CredentialGeneratorComponent, + ForwarderSettingsComponent, SubaddressSettingsComponent, - UsernameSettingsComponent, PasswordGeneratorComponent, - PasswordSettingsComponent, PassphraseSettingsComponent, + PasswordSettingsComponent, UsernameGeneratorComponent, + UsernameSettingsComponent, ], exports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent], }) diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index 37c40ce8b1..f6ec1b17e2 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -21,9 +21,9 @@ import { Generators, PasswordAlgorithm, GeneratedCredential, - CredentialGeneratorInfo, CredentialAlgorithm, isPasswordAlgorithm, + AlgorithmInfo, } from "@bitwarden/generator-core"; /** Options group for passwords */ @@ -52,36 +52,6 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { /** tracks the currently selected credential type */ protected credentialType$ = new BehaviorSubject(null); - /** - * Emits the copy button aria-label respective of the selected credential - * - * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. - */ - protected credentialTypeCopyLabel$ = this.credentialType$.pipe( - map((cred) => { - if (cred === "password") { - return this.i18nService.t("copyPassword"); - } - - return this.i18nService.t("copyPassphrase"); - }), - ); - - /** - * Emits the generate button aria-label respective of the selected credential - * - * FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`. - */ - protected credentialTypeGenerateLabel$ = this.credentialType$.pipe( - map((cred) => { - if (cred === "password") { - return this.i18nService.t("generatePassword"); - } - - return this.i18nService.t("generatePassphrase"); - }), - ); - /** Emits the last generated value. */ protected readonly value$ = new BehaviorSubject(""); @@ -208,12 +178,28 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { protected passwordOptions$ = new BehaviorSubject[]>([]); /** tracks the currently selected credential type */ - protected algorithm$ = new ReplaySubject(1); + protected algorithm$ = new ReplaySubject(1); - private toOptions(algorithms: CredentialGeneratorInfo[]) { + /** + * Emits the copy button aria-label respective of the selected credential type + */ + protected credentialTypeCopyLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); + + /** + * Emits the generate button aria-label respective of the selected credential type + */ + protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); + + private toOptions(algorithms: AlgorithmInfo[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: algorithm.id, - label: this.i18nService.t(algorithm.nameKey), + label: this.i18nService.t(algorithm.name), })); return options; diff --git a/libs/tools/generator/components/src/username-generator.component.html b/libs/tools/generator/components/src/username-generator.component.html index ad8cd79612..3d175f32f7 100644 --- a/libs/tools/generator/components/src/username-generator.component.html +++ b/libs/tools/generator/components/src/username-generator.component.html @@ -3,45 +3,54 @@
- -
-
{{ "options" | i18n }}
+

{{ "options" | i18n }}

-
+ {{ "type" | i18n }} - + {{ credentialTypeHint$ | async }}
+
+ + {{ "service" | i18n }} + + +
+ this.toOptions(algorithms)), + map((algorithms) => { + const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id)); + const usernameOptions = this.toOptions(usernames); + usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwarder") }); + + const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id)); + const forwarderOptions = this.toOptions(forwarders); + forwarderOptions.unshift({ value: NONE_SELECTED, label: this.i18nService.t("select") }); + + return [usernameOptions, forwarderOptions] as const; + }), takeUntil(this.destroyed), ) - .subscribe(this.typeOptions$); + .subscribe(([usernames, forwarders]) => { + this.typeOptions$.next(usernames); + this.forwarderOptions$.next(forwarders); + }); this.algorithm$ .pipe( - map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)), + map((a) => a?.description), takeUntil(this.destroyed), ) .subscribe((hint) => { @@ -103,7 +137,22 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { // wire up the generator this.algorithm$ .pipe( + filter((algorithm) => !!algorithm), switchMap((algorithm) => this.typeToGenerator$(algorithm.id)), + catchError((error: unknown, generator) => { + if (typeof error === "string") { + this.toastService.showToast({ + message: error, + variant: "error", + title: "", + }); + } else { + this.logService.error(error); + } + + // continue with origin stream + return generator; + }), takeUntil(this.destroyed), ) .subscribe((generated) => { @@ -115,20 +164,96 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { }); }); + // normalize cascade selections; introduce subjects to allow changes + // from user selections and changes from preference updates to + // update the template + type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm }; + const activeIdentifier$ = new Subject(); + const activeForwarder$ = new Subject(); + + this.username.valueChanges + .pipe( + map( + (username): CascadeValue => + username.nav === FORWARDER + ? { nav: username.nav } + : { nav: username.nav, algorithm: JSON.parse(username.nav) }, + ), + takeUntil(this.destroyed), + ) + .subscribe(activeIdentifier$); + + this.forwarder.valueChanges + .pipe( + map( + (forwarder): CascadeValue => + forwarder.nav === NONE_SELECTED + ? { nav: forwarder.nav } + : { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) }, + ), + takeUntil(this.destroyed), + ) + .subscribe(activeForwarder$); + + // update forwarder cascade visibility + combineLatest([activeIdentifier$, activeForwarder$]) + .pipe( + map(([username, forwarder]) => { + const showForwarder = !username.algorithm; + const forwarderId = + showForwarder && isForwarderIntegration(forwarder.algorithm) + ? forwarder.algorithm.forwarder + : null; + return [showForwarder, forwarderId] as const; + }), + distinctUntilChanged((prev, next) => prev[0] === next[0] && prev[1] === next[1]), + takeUntil(this.destroyed), + ) + .subscribe(([showForwarder, forwarderId]) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.showForwarder$.next(showForwarder); + this.forwarderId$.next(forwarderId); + }); + }); + + // update active algorithm + combineLatest([activeIdentifier$, activeForwarder$]) + .pipe( + map(([username, forwarder]) => { + const selection = username.algorithm ?? forwarder.algorithm; + if (selection) { + return this.generatorService.algorithm(selection); + } else { + return null; + } + }), + distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)), + takeUntil(this.destroyed), + ) + .subscribe((algorithm) => { + // update subjects within the angular zone so that the + // template bindings refresh immediately + this.zone.run(() => { + this.algorithm$.next(algorithm); + }); + }); + // assume the last-visible generator algorithm is the user's preferred one const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ }); - this.credential.valueChanges + this.algorithm$ .pipe( - filter(({ type }) => !!type), + filter((algorithm) => !!algorithm), withLatestFrom(preferences), takeUntil(this.destroyed), ) - .subscribe(([{ type }, preference]) => { - if (isEmailAlgorithm(type)) { - preference.email.algorithm = type; + .subscribe(([algorithm, preference]) => { + if (isEmailAlgorithm(algorithm.id)) { + preference.email.algorithm = algorithm.id; preference.email.updated = new Date(); - } else if (isUsernameAlgorithm(type)) { - preference.username.algorithm = type; + } else if (isUsernameAlgorithm(algorithm.id)) { + preference.username.algorithm = algorithm.id; preference.username.updated = new Date(); } else { return; @@ -137,31 +262,61 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { preferences.next(preference); }); - // populate the form with the user's preferences to kick off interactivity - preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username }) => { - // this generator supports email & username; the last preference - // set by the user "wins" - const preference = email.updated > username.updated ? email.algorithm : username.algorithm; - - // break subscription loop - this.credential.setValue({ type: preference }, { emitEvent: false }); - - const algorithm = this.generatorService.algorithm(preference); - // update subjects within the angular zone so that the - // template bindings refresh immediately - this.zone.run(() => { - this.algorithm$.next(algorithm); - }); - }); - - // generate on load unless the generator prohibits it - this.algorithm$ + preferences .pipe( - distinctUntilChanged((prev, next) => prev.id === next.id), - filter((a) => !a.onlyOnRequest), + map(({ email, username }) => { + const forwarderPref = isForwarderIntegration(email.algorithm) ? email : null; + const usernamePref = email.updated > username.updated ? email : username; + + // inject drilldown flags + const forwarderNav = !forwarderPref + ? NONE_SELECTED + : JSON.stringify(forwarderPref.algorithm); + const userNav = forwarderPref ? FORWARDER : JSON.stringify(usernamePref.algorithm); + + // construct cascade metadata + const cascade = { + username: { + selection: { nav: userNav }, + active: { + nav: userNav, + algorithm: forwarderPref ? null : usernamePref.algorithm, + }, + }, + forwarder: { + selection: { nav: forwarderNav }, + active: { + nav: forwarderNav, + algorithm: forwarderPref?.algorithm, + }, + }, + }; + + return cascade; + }), takeUntil(this.destroyed), ) - .subscribe(() => this.generate$.next()); + .subscribe(({ username, forwarder }) => { + // update navigation; break subscription loop + this.username.setValue(username.selection, { emitEvent: false }); + this.forwarder.setValue(forwarder.selection, { emitEvent: false }); + + // update cascade visibility + activeIdentifier$.next(username.active); + activeForwarder$.next(forwarder.active); + }); + + // automatically regenerate when the algorithm switches if the algorithm + // allows it; otherwise set a placeholder + this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => { + this.zone.run(() => { + if (!a || a.onlyOnRequest) { + this.value$.next("-"); + } else { + this.generate$.next(); + } + }); + }); } private typeToGenerator$(type: CredentialAlgorithm) { @@ -179,17 +334,52 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { case "username": return this.generatorService.generate$(Generators.username, dependencies); - - default: - throw new Error(`Invalid generator type: "${type}"`); } + + if (isForwarderIntegration(type)) { + const forwarder = getForwarderConfiguration(type.forwarder); + const configuration = toCredentialGeneratorConfiguration(forwarder); + return this.generatorService.generate$(configuration, dependencies); + } + + throw new Error(`Invalid generator type: "${type}"`); } /** Lists the credential types supported by the component. */ - protected typeOptions$ = new BehaviorSubject[]>([]); + protected typeOptions$ = new BehaviorSubject[]>([]); + + /** Tracks the currently selected forwarder. */ + protected forwarderId$ = new BehaviorSubject(null); + + /** Lists the credential types supported by the component. */ + protected forwarderOptions$ = new BehaviorSubject[]>([]); + + /** Tracks forwarder control visibility */ + protected showForwarder$ = new BehaviorSubject(false); /** tracks the currently selected credential type */ - protected algorithm$ = new ReplaySubject(1); + protected algorithm$ = new ReplaySubject(1); + + protected showAlgorithm$ = this.algorithm$.pipe( + combineLatestWith(this.showForwarder$), + map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)), + ); + + /** + * Emits the copy button aria-label respective of the selected credential type + */ + protected credentialTypeCopyLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); + + /** + * Emits the generate button aria-label respective of the selected credential type + */ + protected credentialTypeGenerateLabel$ = this.algorithm$.pipe( + filter((algorithm) => !!algorithm), + map(({ copy }) => copy), + ); /** Emits hint key for the currently selected credential type */ protected credentialTypeHint$ = new ReplaySubject(1); @@ -203,10 +393,10 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy { /** Emits when a new credential is requested */ protected readonly generate$ = new Subject(); - private toOptions(algorithms: CredentialGeneratorInfo[]) { - const options: Option[] = algorithms.map((algorithm) => ({ - value: algorithm.id, - label: this.i18nService.t(algorithm.nameKey), + private toOptions(algorithms: AlgorithmInfo[]) { + const options: Option[] = algorithms.map((algorithm) => ({ + value: JSON.stringify(algorithm.id), + label: this.i18nService.t(algorithm.name), })); return options; diff --git a/libs/tools/generator/components/src/util.ts b/libs/tools/generator/components/src/util.ts index 2049a285e2..d6cd4e6fba 100644 --- a/libs/tools/generator/components/src/util.ts +++ b/libs/tools/generator/components/src/util.ts @@ -63,7 +63,7 @@ function getConstraint( ) { if (policy && key in policy) { return policy[key] ?? config[key]; - } else if (key in config) { + } else if (config && key in config) { return config[key]; } } diff --git a/libs/tools/generator/core/src/data/generator-types.ts b/libs/tools/generator/core/src/data/generator-types.ts index 6c351b82e3..e54ec34e49 100644 --- a/libs/tools/generator/core/src/data/generator-types.ts +++ b/libs/tools/generator/core/src/data/generator-types.ts @@ -5,7 +5,7 @@ export const PasswordAlgorithms = Object.freeze(["password", "passphrase"] as co export const UsernameAlgorithms = Object.freeze(["username"] as const); /** Types of email addresses that may be generated by the credential generator */ -export const EmailAlgorithms = Object.freeze(["catchall", "forwarder", "subaddress"] as const); +export const EmailAlgorithms = Object.freeze(["catchall", "subaddress"] as const); /** All types of credentials that may be generated by the credential generator */ export const CredentialAlgorithms = Object.freeze([ diff --git a/libs/tools/generator/core/src/data/generators.ts b/libs/tools/generator/core/src/data/generators.ts index 2c96b0c2d3..d86eb52a8f 100644 --- a/libs/tools/generator/core/src/data/generators.ts +++ b/libs/tools/generator/core/src/data/generators.ts @@ -1,9 +1,15 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; -import { Randomizer } from "../abstractions"; -import { EmailRandomizer, PasswordRandomizer, UsernameRandomizer } from "../engine"; +import { + EmailRandomizer, + ForwarderConfiguration, + PasswordRandomizer, + UsernameRandomizer, +} from "../engine"; +import { Forwarder } from "../engine/forwarder"; import { DefaultPolicyEvaluator, DynamicPasswordPolicyConstraints, @@ -25,6 +31,7 @@ import { CredentialGenerator, CredentialGeneratorConfiguration, EffUsernameGenerationOptions, + GeneratorDependencyProvider, NoPolicy, PassphraseGenerationOptions, PassphraseGeneratorPolicy, @@ -45,10 +52,15 @@ const PASSPHRASE = Object.freeze({ id: "passphrase", category: "password", nameKey: "passphrase", + generateKey: "generatePassphrase", + copyKey: "copyPassphrase", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new PasswordRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new PasswordRandomizer(dependencies.randomizer); }, }, settings: { @@ -82,10 +94,15 @@ const PASSWORD = Object.freeze({ id: "password", category: "password", nameKey: "password", + generateKey: "generatePassword", + copyKey: "copyPassword", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new PasswordRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new PasswordRandomizer(dependencies.randomizer); }, }, settings: { @@ -127,10 +144,15 @@ const USERNAME = Object.freeze({ id: "username", category: "username", nameKey: "randomWord", + generateKey: "generateUsername", + copyKey: "copyUsername", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new UsernameRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new UsernameRandomizer(dependencies.randomizer); }, }, settings: { @@ -158,10 +180,15 @@ const CATCHALL = Object.freeze({ category: "email", nameKey: "catchallEmail", descriptionKey: "catchallEmailDesc", + generateKey: "generateEmail", + copyKey: "copyEmail", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new EmailRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new EmailRandomizer(dependencies.randomizer); }, }, settings: { @@ -189,10 +216,15 @@ const SUBADDRESS = Object.freeze({ category: "email", nameKey: "plusAddressedEmail", descriptionKey: "plusAddressedEmailDesc", + generateKey: "generateEmail", + copyKey: "copyEmail", onlyOnRequest: false, + request: [], engine: { - create(randomizer: Randomizer): CredentialGenerator { - return new EmailRandomizer(randomizer); + create( + dependencies: GeneratorDependencyProvider, + ): CredentialGenerator { + return new EmailRandomizer(dependencies.randomizer); }, }, settings: { @@ -215,6 +247,48 @@ const SUBADDRESS = Object.freeze({ }, } satisfies CredentialGeneratorConfiguration); +export function toCredentialGeneratorConfiguration( + configuration: ForwarderConfiguration, +) { + const forwarder = Object.freeze({ + id: { forwarder: configuration.id }, + category: "email", + nameKey: configuration.name, + descriptionKey: "forwardedEmailDesc", + generateKey: "generateEmail", + copyKey: "copyEmail", + onlyOnRequest: true, + request: configuration.forwarder.request, + engine: { + create(dependencies: GeneratorDependencyProvider) { + // FIXME: figure out why `configuration` fails to typecheck + const config: any = configuration; + return new Forwarder(config, dependencies.client, dependencies.i18nService); + }, + }, + settings: { + initial: configuration.forwarder.defaultSettings, + constraints: configuration.forwarder.settingsConstraints, + account: configuration.forwarder.settings, + }, + policy: { + type: PolicyType.PasswordGenerator, + disabledValue: {}, + combine(_acc: NoPolicy, _policy: Policy) { + return {}; + }, + createEvaluator(_policy: NoPolicy) { + return new DefaultPolicyEvaluator(); + }, + toConstraints(_policy: NoPolicy) { + return new IdentityConstraint(); + }, + }, + } satisfies CredentialGeneratorConfiguration); + + return forwarder; +} + /** Generator configurations */ export const Generators = Object.freeze({ /** Passphrase generator configuration */ diff --git a/libs/tools/generator/core/src/data/integrations.ts b/libs/tools/generator/core/src/data/integrations.ts index 6132891b36..71c80fc9db 100644 --- a/libs/tools/generator/core/src/data/integrations.ts +++ b/libs/tools/generator/core/src/data/integrations.ts @@ -1,3 +1,7 @@ +import { IntegrationId } from "@bitwarden/common/tools/integration"; +import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; + +import { ForwarderConfiguration } from "../engine"; import { AddyIo } from "../integration/addy-io"; import { DuckDuckGo } from "../integration/duck-duck-go"; import { Fastmail } from "../integration/fastmail"; @@ -5,6 +9,13 @@ import { FirefoxRelay } from "../integration/firefox-relay"; import { ForwardEmail } from "../integration/forward-email"; import { SimpleLogin } from "../integration/simple-login"; +/** Fixed list of integrations available to the application + * @example + * + * // Use `toCredentialGeneratorConfiguration(id :ForwarderIntegration)` + * // to convert an integration to a generator configuration + * const generator = toCredentialGeneratorConfiguration(Integrations.AddyIo); + */ export const Integrations = Object.freeze({ AddyIo, DuckDuckGo, @@ -13,3 +24,15 @@ export const Integrations = Object.freeze({ ForwardEmail, SimpleLogin, } as const); + +const integrations = new Map(Object.values(Integrations).map((i) => [i.id, i])); + +export function getForwarderConfiguration(id: IntegrationId): ForwarderConfiguration { + const maybeForwarder = integrations.get(id); + + if (maybeForwarder && "forwarder" in maybeForwarder) { + return maybeForwarder as ForwarderConfiguration; + } else { + return null; + } +} diff --git a/libs/tools/generator/core/src/engine/forwarder-configuration.ts b/libs/tools/generator/core/src/engine/forwarder-configuration.ts index 95c9add140..7813f45739 100644 --- a/libs/tools/generator/core/src/engine/forwarder-configuration.ts +++ b/libs/tools/generator/core/src/engine/forwarder-configuration.ts @@ -1,11 +1,14 @@ import { UserKeyDefinition } from "@bitwarden/common/platform/state"; import { IntegrationConfiguration } from "@bitwarden/common/tools/integration/integration-configuration"; -import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; +import { ApiSettings, SelfHostedApiSettings } from "@bitwarden/common/tools/integration/rpc"; import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc/integration-request"; import { RpcConfiguration } from "@bitwarden/common/tools/integration/rpc/rpc-definition"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; +import { Constraints } from "@bitwarden/common/tools/types"; import { ForwarderContext } from "./forwarder-context"; +import { EmailDomainSettings, EmailPrefixSettings } from "./settings"; /** Mixin for transmitting `getAccountId` result. */ export type AccountRequest = { @@ -24,8 +27,16 @@ export type GetAccountIdRpcDef< Request extends IntegrationRequest = IntegrationRequest, > = RpcConfiguration, string>; +export type ForwarderRequestFields = keyof (ApiSettings & + SelfHostedApiSettings & + EmailDomainSettings & + EmailPrefixSettings); + /** Forwarder-specific static definition */ export type ForwarderConfiguration< + // FIXME: simply forwarder settings to an object that has all + // settings properties. The runtime dynamism should be limited + // to which have values, not which have properties listed. Settings extends ApiSettings, Request extends IntegrationRequest = IntegrationRequest, > = IntegrationConfiguration & { @@ -34,12 +45,30 @@ export type ForwarderConfiguration< /** default value of all fields */ defaultSettings: Partial; - /** forwarder settings storage */ + settingsConstraints: Constraints; + + /** Well-known fields to display on the forwarder screen */ + request: readonly ForwarderRequestFields[]; + + /** forwarder settings storage + * @deprecated use local.settings instead + */ settings: UserKeyDefinition; - /** forwarder settings import buffer; `undefined` when there is no buffer. */ + /** forwarder settings import buffer; `undefined` when there is no buffer. + * @deprecated use local.settings import + */ importBuffer?: BufferedKeyDefinition; + /** locally stored data; forwarder-partitioned */ + local: { + /** integration settings storage */ + settings: ObjectKey; + + /** plaintext import buffer - used during data migrations */ + import?: ObjectKey, Settings>; + }; + /** createForwardingEmail RPC definition */ createForwardingEmail: CreateForwardingEmailRpcDef; diff --git a/libs/tools/generator/core/src/engine/forwarder.ts b/libs/tools/generator/core/src/engine/forwarder.ts new file mode 100644 index 0000000000..523c6fdf1e --- /dev/null +++ b/libs/tools/generator/core/src/engine/forwarder.ts @@ -0,0 +1,75 @@ +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { + ApiSettings, + IntegrationRequest, + RestClient, +} from "@bitwarden/common/tools/integration/rpc"; +import { GenerationRequest } from "@bitwarden/common/tools/types"; + +import { CredentialGenerator, GeneratedCredential } from "../types"; + +import { AccountRequest, ForwarderConfiguration } from "./forwarder-configuration"; +import { ForwarderContext } from "./forwarder-context"; +import { CreateForwardingAddressRpc, GetAccountIdRpc } from "./rpc"; + +/** Generation algorithms that query an email forwarding service to + * create anonymized email addresses. + */ +export class Forwarder implements CredentialGenerator { + /** Instantiates the email forwarder engine + * @param configuration The forwarder to query + * @param client requests data from the forwarding service + * @param i18nService localizes messages sent to the forwarding service + * and user-addressable errors + */ + constructor( + private configuration: ForwarderConfiguration, + private client: RestClient, + private i18nService: I18nService, + ) {} + + async generate(request: GenerationRequest, settings: ApiSettings) { + const requestOptions: IntegrationRequest & AccountRequest = { website: request.website }; + + const getAccount = await this.getAccountId(this.configuration, settings); + if (getAccount) { + requestOptions.accountId = await this.client.fetchJson(getAccount, requestOptions); + } + + const create = this.createForwardingAddress(this.configuration, settings); + const result = await this.client.fetchJson(create, requestOptions); + const id = { forwarder: this.configuration.id }; + + return new GeneratedCredential(result, id, Date.now()); + } + + private createContext( + configuration: ForwarderConfiguration, + settings: Settings, + ) { + return new ForwarderContext(configuration, settings, this.i18nService); + } + + private createForwardingAddress( + configuration: ForwarderConfiguration, + settings: Settings, + ) { + const context = this.createContext(configuration, settings); + const rpc = new CreateForwardingAddressRpc(configuration, context); + return rpc; + } + + private getAccountId( + configuration: ForwarderConfiguration, + settings: Settings, + ) { + if (!configuration.forwarder.getAccountId) { + return null; + } + + const context = this.createContext(configuration, settings); + const rpc = new GetAccountIdRpc(configuration, context); + + return rpc; + } +} diff --git a/libs/tools/generator/core/src/integration/addy-io.ts b/libs/tools/generator/core/src/integration/addy-io.ts index 8f594827e9..2d265ca9bf 100644 --- a/libs/tools/generator/core/src/integration/addy-io.ts +++ b/libs/tools/generator/core/src/integration/addy-io.ts @@ -1,11 +1,18 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest, SelfHostedApiSettings, } from "@bitwarden/common/tools/integration/rpc"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -44,6 +51,40 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + request: ["token", "baseUrl", "domain"], + settingsConstraints: { + token: { required: true }, + domain: { required: true }, + baseUrl: {}, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.AddyIo.local.settings", + key: "addyIoForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.AddyIo.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token", "baseUrl", "domain"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, AddyIoSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "addyIoForwarder", { deserializer: (value) => value, clearOn: [], @@ -52,7 +93,6 @@ const forwarder = Object.freeze({ deserializer: (value) => value, clearOn: ["logout"], }), - createForwardingEmail, } as const); export const AddyIo = Object.freeze({ diff --git a/libs/tools/generator/core/src/integration/duck-duck-go.ts b/libs/tools/generator/core/src/integration/duck-duck-go.ts index 0c13ac6b63..4c1d672cc6 100644 --- a/libs/tools/generator/core/src/integration/duck-duck-go.ts +++ b/libs/tools/generator/core/src/integration/duck-duck-go.ts @@ -1,7 +1,14 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -36,6 +43,38 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + request: ["token"], + settingsConstraints: { + token: { required: true }, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.DuckDuckGo.local.settings", + key: "duckDuckGoForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.DuckDuckGo.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, DuckDuckGoSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "duckDuckGoForwarder", { deserializer: (value) => value, clearOn: [], @@ -44,7 +83,6 @@ const forwarder = Object.freeze({ deserializer: (value) => value, clearOn: ["logout"], }), - createForwardingEmail, } as const); // integration-wide configuration diff --git a/libs/tools/generator/core/src/integration/fastmail.ts b/libs/tools/generator/core/src/integration/fastmail.ts index 0987540e03..13aa8db624 100644 --- a/libs/tools/generator/core/src/integration/fastmail.ts +++ b/libs/tools/generator/core/src/integration/fastmail.ts @@ -1,7 +1,14 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, @@ -101,6 +108,41 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + getAccountId, + request: ["token"], + settingsConstraints: { + token: { required: true }, + domain: { required: true }, + prefix: {}, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.Fastmail.local.settings" + key: "fastmailForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.Fastmail.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, FastmailSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "fastmailForwarder", { deserializer: (value) => value, clearOn: [], @@ -109,8 +151,6 @@ const forwarder = Object.freeze({ deserializer: (value) => value, clearOn: ["logout"], }), - createForwardingEmail, - getAccountId, } as const); // integration-wide configuration diff --git a/libs/tools/generator/core/src/integration/firefox-relay.ts b/libs/tools/generator/core/src/integration/firefox-relay.ts index 4feb8a0bd9..9c965a4c9c 100644 --- a/libs/tools/generator/core/src/integration/firefox-relay.ts +++ b/libs/tools/generator/core/src/integration/firefox-relay.ts @@ -1,7 +1,14 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -40,6 +47,38 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + request: ["token"], + settingsConstraints: { + token: { required: true }, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.Firefox.local.settings", + key: "firefoxRelayForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.Firefox.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, FirefoxRelaySettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "firefoxRelayForwarder", { deserializer: (value) => value, clearOn: [], @@ -52,7 +91,6 @@ const forwarder = Object.freeze({ clearOn: ["logout"], }, ), - createForwardingEmail, } as const); // integration-wide configuration diff --git a/libs/tools/generator/core/src/integration/forward-email.ts b/libs/tools/generator/core/src/integration/forward-email.ts index c4ef21d9d3..a128159fcd 100644 --- a/libs/tools/generator/core/src/integration/forward-email.ts +++ b/libs/tools/generator/core/src/integration/forward-email.ts @@ -1,7 +1,14 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -43,6 +50,38 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + request: ["token", "domain"], + settingsConstraints: { + token: { required: true }, + domain: { required: true }, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.ForwardEmail.local.settings", + key: "forwardEmailForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.ForwardEmail.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token", "domain"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, ForwardEmailSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "forwardEmailForwarder", { deserializer: (value) => value, clearOn: [], diff --git a/libs/tools/generator/core/src/integration/simple-login.ts b/libs/tools/generator/core/src/integration/simple-login.ts index 88730d0578..d4b297fc37 100644 --- a/libs/tools/generator/core/src/integration/simple-login.ts +++ b/libs/tools/generator/core/src/integration/simple-login.ts @@ -1,11 +1,18 @@ -import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { + GENERATOR_DISK, + GENERATOR_MEMORY, + UserKeyDefinition, +} from "@bitwarden/common/platform/state"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, IntegrationRequest, SelfHostedApiSettings, } from "@bitwarden/common/tools/integration/rpc"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { PublicClassifier } from "@bitwarden/common/tools/public-classifier"; import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { ForwarderConfiguration, ForwarderContext } from "../engine"; import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration"; @@ -45,6 +52,38 @@ const createForwardingEmail = Object.freeze({ // forwarder configuration const forwarder = Object.freeze({ defaultSettings, + createForwardingEmail, + request: ["token", "baseUrl"], + settingsConstraints: { + token: { required: true }, + }, + local: { + settings: { + // FIXME: integration should issue keys at runtime + // based on integrationId & extension metadata + // e.g. key: "forwarder.SimpleLogin.local.settings", + key: "simpleLoginForwarder", + target: "object", + format: "classified", + classifier: new PrivateClassifier(), + state: GENERATOR_DISK, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ObjectKey, + import: { + key: "forwarder.SimpleLogin.local.import", + target: "object", + format: "plain", + classifier: new PublicClassifier(["token", "baseUrl"]), + state: GENERATOR_MEMORY, + options: { + deserializer: (value) => value, + clearOn: ["logout", "lock"], + }, + } satisfies ObjectKey, SimpleLoginSettings>, + }, settings: new UserKeyDefinition(GENERATOR_DISK, "simpleLoginForwarder", { deserializer: (value) => value, clearOn: [], @@ -57,7 +96,6 @@ const forwarder = Object.freeze({ clearOn: ["logout"], }, ), - createForwardingEmail, } as const); // integration-wide configuration diff --git a/libs/tools/generator/core/src/rx.spec.ts b/libs/tools/generator/core/src/rx.spec.ts deleted file mode 100644 index b98e79bb07..0000000000 --- a/libs/tools/generator/core/src/rx.spec.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { EmptyError, Subject, tap } from "rxjs"; - -import { anyComplete, on, ready } from "./rx"; - -describe("anyComplete", () => { - it("emits true when its input completes", () => { - const input$ = new Subject(); - - const emissions: boolean[] = []; - anyComplete(input$).subscribe((e) => emissions.push(e)); - input$.complete(); - - expect(emissions).toEqual([true]); - }); - - it("completes when its input is already complete", () => { - const input = new Subject(); - input.complete(); - - let completed = false; - anyComplete(input).subscribe({ complete: () => (completed = true) }); - - expect(completed).toBe(true); - }); - - it("completes when any input completes", () => { - const input$ = new Subject(); - const completing$ = new Subject(); - - let completed = false; - anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) }); - completing$.complete(); - - expect(completed).toBe(true); - }); - - it("ignores emissions", () => { - const input$ = new Subject(); - - const emissions: boolean[] = []; - anyComplete(input$).subscribe((e) => emissions.push(e)); - input$.next(1); - input$.next(2); - input$.complete(); - - expect(emissions).toEqual([true]); - }); - - it("forwards errors", () => { - const input$ = new Subject(); - const expected = { some: "error" }; - - let error = null; - anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) }); - input$.error(expected); - - expect(error).toEqual(expected); - }); -}); - -describe("ready", () => { - it("connects when subscribed", () => { - const watch$ = new Subject(); - let connected = false; - const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); - - // precondition: ready$ should be cold - const ready$ = source$.pipe(ready(watch$)); - expect(connected).toBe(false); - - ready$.subscribe(); - - expect(connected).toBe(true); - }); - - it("suppresses source emissions until its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - watch$.next(); - - expect(results).toEqual([1]); - }); - - it("suppresses source emissions until all watches emit", () => { - const watchA$ = new Subject(); - const watchB$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready([watchA$, watchB$])); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // preconditions: no emissions - source$.next(1); - expect(results).toEqual([]); - watchA$.next(); - expect(results).toEqual([]); - - watchB$.next(); - - expect(results).toEqual([1]); - }); - - it("emits the last source emission when its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - // precondition: no emissions - source$.next(1); - expect(results).toEqual([]); - - source$.next(2); - watch$.next(); - - expect(results).toEqual([2]); - }); - - it("emits all source emissions after its watch emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - source$.next(2); - - expect(results).toEqual([1, 2]); - }); - - it("ignores repeated watch emissions", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const results: number[] = []; - ready$.subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - watch$.next(); - source$.next(2); - watch$.next(); - - expect(results).toEqual([1, 2]); - }); - - it("completes when its source completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - let completed = false; - ready$.subscribe({ complete: () => (completed = true) }); - - source$.complete(); - - expect(completed).toBeTruthy(); - }); - - it("errors when its source errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - source$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - const expected = { some: "error" }; - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch completes before emitting", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const ready$ = source$.pipe(ready(watch$)); - let error = null; - ready$.subscribe({ error: (e: unknown) => (error = e) }); - - watch$.complete(); - - expect(error).toBeInstanceOf(EmptyError); - }); -}); - -describe("on", () => { - it("connects when subscribed", () => { - const watch$ = new Subject(); - let connected = false; - const source$ = new Subject().pipe(tap({ subscribe: () => (connected = true) })); - - // precondition: on$ should be cold - const on$ = source$.pipe(on(watch$)); - expect(connected).toBeFalsy(); - - on$.subscribe(); - - expect(connected).toBeTruthy(); - }); - - it("suppresses source emissions until `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - // precondition: on$ should be cold - source$.next(1); - expect(results).toEqual([]); - - watch$.next(); - - expect(results).toEqual([1]); - }); - - it("repeats source emissions when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - source$.next(1); - - watch$.next(); - watch$.next(); - - expect(results).toEqual([1, 1]); - }); - - it("updates source emissions when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - source$.next(1); - watch$.next(); - source$.next(2); - watch$.next(); - - expect(results).toEqual([1, 2]); - }); - - it("emits a value when `on` emits before the source is ready", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - watch$.next(); - source$.next(1); - - expect(results).toEqual([1]); - }); - - it("ignores repeated `on` emissions before the source is ready", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - - watch$.next(); - watch$.next(); - source$.next(1); - - expect(results).toEqual([1]); - }); - - it("emits only the latest source emission when `on` emits", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const results: number[] = []; - source$.pipe(on(watch$)).subscribe((n) => results.push(n)); - source$.next(1); - - watch$.next(); - - source$.next(2); - source$.next(3); - watch$.next(); - - expect(results).toEqual([1, 3]); - }); - - it("completes when its source completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - let complete: boolean = false; - source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); - - source$.complete(); - - expect(complete).toBeTruthy(); - }); - - it("completes when its watch completes", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - let complete: boolean = false; - source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) }); - - watch$.complete(); - - expect(complete).toBeTruthy(); - }); - - it("errors when its source errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const expected = { some: "error" }; - let error = null; - source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); - - source$.error(expected); - - expect(error).toEqual(expected); - }); - - it("errors when its watch errors", () => { - const watch$ = new Subject(); - const source$ = new Subject(); - const expected = { some: "error" }; - let error = null; - source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) }); - - watch$.error(expected); - - expect(error).toEqual(expected); - }); -}); diff --git a/libs/tools/generator/core/src/rx.ts b/libs/tools/generator/core/src/rx.ts index 851b6cfe7c..070d34d37d 100644 --- a/libs/tools/generator/core/src/rx.ts +++ b/libs/tools/generator/core/src/rx.ts @@ -1,18 +1,4 @@ -import { - concat, - concatMap, - connect, - endWith, - first, - ignoreElements, - map, - Observable, - pipe, - race, - ReplaySubject, - takeUntil, - zip, -} from "rxjs"; +import { map, pipe } from "rxjs"; import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx"; @@ -51,86 +37,3 @@ export function newDefaultEvaluator() { return pipe(map((_) => new DefaultPolicyEvaluator())); }; } - -/** Create an observable that, once subscribed, emits `true` then completes when - * any input completes. If an input is already complete when the subscription - * occurs, it emits immediately. - * @param watch$ the observable(s) to watch for completion; if an array is passed, - * null and undefined members are ignored. If `watch$` is empty, `anyComplete` - * will never complete. - * @returns An observable that emits `true` when any of its inputs - * complete. The observable forwards the first error from its input. - * @remarks This method is particularly useful in combination with `takeUntil` and - * streams that are not guaranteed to complete on their own. - */ -export function anyComplete(watch$: Observable | Observable[]): Observable { - if (Array.isArray(watch$)) { - const completes$ = watch$ - .filter((w$) => !!w$) - .map((w$) => w$.pipe(ignoreElements(), endWith(true))); - const completed$ = race(completes$); - return completed$; - } else { - return watch$.pipe(ignoreElements(), endWith(true)); - } -} - -/** - * Create an observable that delays the input stream until all watches have - * emitted a value. The watched values are not included in the source stream. - * The last emission from the source is output when all the watches have - * emitted at least once. - * @param watch$ the observable(s) to watch for readiness. If `watch$` is empty, - * `ready` will never emit. - * @returns An observable that emits when the source stream emits. The observable - * errors if one of its watches completes before emitting. It also errors if one - * of its watches errors. - */ -export function ready(watch$: Observable | Observable[]) { - const watching$ = Array.isArray(watch$) ? watch$ : [watch$]; - return pipe( - connect>((source$) => { - // this subscription is safe because `source$` connects only after there - // is an external subscriber. - const source = new ReplaySubject(1); - source$.subscribe(source); - - // `concat` is subscribed immediately after it's returned, at which point - // `zip` blocks until all items in `watching$` are ready. If that occurs - // after `source$` is hot, then the replay subject sends the last-captured - // emission through immediately. Otherwise, `ready` waits for the next - // emission - return concat(zip(watching$).pipe(first(), ignoreElements()), source).pipe( - takeUntil(anyComplete(source)), - ); - }), - ); -} - -/** - * Create an observable that emits the latest value of the source stream - * when `watch$` emits. If `watch$` emits before the stream emits, then - * an emission occurs as soon as a value becomes ready. - * @param watch$ the observable that triggers emissions - * @returns An observable that emits when `watch$` emits. The observable - * errors if its source stream errors. It also errors if `on` errors. It - * completes if its watch completes. - * - * @remarks This works like `audit`, but it repeats emissions when - * watch$ fires. - */ -export function on(watch$: Observable) { - return pipe( - connect>((source$) => { - const source = new ReplaySubject(1); - source$.subscribe(source); - - return watch$ - .pipe( - ready(source), - concatMap(() => source.pipe(first())), - ) - .pipe(takeUntil(anyComplete(source))); - }), - ); -} diff --git a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts index 88f1447e98..e11e555d6a 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.spec.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.spec.ts @@ -1,12 +1,17 @@ import { mock } from "jest-mock-extended"; import { BehaviorSubject, filter, firstValueFrom, Subject } from "rxjs"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; import { StateConstraints } from "@bitwarden/common/tools/types"; import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid"; +import { UserKey } from "@bitwarden/common/types/key"; import { FakeStateProvider, @@ -67,15 +72,20 @@ const SomeTime = new Date(1); const SomeAlgorithm = "passphrase"; const SomeCategory = "password"; const SomeNameKey = "passphraseKey"; +const SomeGenerateKey = "generateKey"; +const SomeCopyKey = "copyKey"; // fake the configuration const SomeConfiguration: CredentialGeneratorConfiguration = { id: SomeAlgorithm, category: SomeCategory, nameKey: SomeNameKey, + generateKey: SomeGenerateKey, + copyKey: SomeCopyKey, onlyOnRequest: false, + request: [], engine: { - create: (randomizer) => { + create: (_randomizer) => { return { generate: (request, settings) => { const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo; @@ -159,10 +169,22 @@ const stateProvider = new FakeStateProvider(accountService); // fake randomizer const randomizer = mock(); +const i18nService = mock(); + +const apiService = mock(); + +const encryptService = mock(); + +const cryptoService = mock(); + describe("CredentialGeneratorService", () => { beforeEach(async () => { await accountService.switchAccount(SomeUser); policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable()); + i18nService.t.mockImplementation((key) => key); + apiService.fetch.mockImplementation(() => Promise.resolve(mock())); + const keyAvailable = new BehaviorSubject({} as UserKey); + cryptoService.userKey$.mockReturnValue(keyAvailable); jest.clearAllMocks(); }); @@ -170,7 +192,15 @@ describe("CredentialGeneratorService", () => { it("emits a generation for the active user when subscribed", async () => { const settings = { foo: "value" }; await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); const result = await generated.expectEmission(); @@ -183,7 +213,15 @@ describe("CredentialGeneratorService", () => { const anotherSettings = { foo: "another value" }; await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); await accountService.switchAccount(AnotherUser); @@ -200,7 +238,15 @@ describe("CredentialGeneratorService", () => { const someSettings = { foo: "some value" }; const anotherSettings = { foo: "another value" }; await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const generated = new ObservableTracker(generator.generate$(SomeConfiguration)); await stateProvider.setUserState(SettingsKey, anotherSettings, SomeUser); @@ -220,7 +266,15 @@ describe("CredentialGeneratorService", () => { it("includes `website$`'s last emitted value", async () => { const settings = { foo: "value" }; await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const website$ = new BehaviorSubject("some website"); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ })); @@ -233,7 +287,15 @@ describe("CredentialGeneratorService", () => { it("errors when `website$` errors", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const website$ = new BehaviorSubject("some website"); let error = null; @@ -250,7 +312,15 @@ describe("CredentialGeneratorService", () => { it("completes when `website$` completes", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const website$ = new BehaviorSubject("some website"); let completed = false; @@ -268,7 +338,15 @@ describe("CredentialGeneratorService", () => { it("emits a generation for a specific user when `user$` supplied", async () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); @@ -280,7 +358,15 @@ describe("CredentialGeneratorService", () => { it("emits a generation for a specific user when `user$` emits", async () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.pipe(filter((u) => !!u)); const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ })); @@ -296,7 +382,15 @@ describe("CredentialGeneratorService", () => { it("errors when `user$` errors", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(SomeUser); let error = null; @@ -313,7 +407,15 @@ describe("CredentialGeneratorService", () => { it("completes when `user$` completes", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(SomeUser); let completed = false; @@ -331,7 +433,15 @@ describe("CredentialGeneratorService", () => { it("emits a generation only when `on$` emits", async () => { // This test breaks from arrange/act/assert because it is testing causality await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const on$ = new Subject(); const results: any[] = []; @@ -365,7 +475,15 @@ describe("CredentialGeneratorService", () => { it("errors when `on$` errors", async () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const on$ = new Subject(); let error: any = null; @@ -383,7 +501,15 @@ describe("CredentialGeneratorService", () => { it("completes when `on$` completes", async () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const on$ = new Subject(); let complete = false; @@ -406,54 +532,86 @@ describe("CredentialGeneratorService", () => { describe("algorithms", () => { it("outputs password generation metadata", () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = generator.algorithms("password"); - expect(result).toContain(Generators.password); - expect(result).toContain(Generators.passphrase); + expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy(); // this test shouldn't contain entries outside of the current category - expect(result).not.toContain(Generators.username); - expect(result).not.toContain(Generators.catchall); + expect(result.some((a) => a.id === Generators.username.id)).toBeFalsy(); + expect(result.some((a) => a.id === Generators.catchall.id)).toBeFalsy(); }); it("outputs username generation metadata", () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = generator.algorithms("username"); - expect(result).toContain(Generators.username); + expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); // this test shouldn't contain entries outside of the current category - expect(result).not.toContain(Generators.catchall); - expect(result).not.toContain(Generators.password); + expect(result.some((a) => a.id === Generators.catchall.id)).toBeFalsy(); + expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy(); }); it("outputs email generation metadata", () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = generator.algorithms("email"); - expect(result).toContain(Generators.catchall); - expect(result).toContain(Generators.subaddress); + expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); // this test shouldn't contain entries outside of the current category - expect(result).not.toContain(Generators.username); - expect(result).not.toContain(Generators.password); + expect(result.some((a) => a.id === Generators.username.id)).toBeFalsy(); + expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy(); }); it("combines metadata across categories", () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = generator.algorithms(["username", "email"]); - expect(result).toContain(Generators.username); - expect(result).toContain(Generators.catchall); - expect(result).toContain(Generators.subaddress); + expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); // this test shouldn't contain entries outside of the current categories - expect(result).not.toContain(Generators.password); + expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy(); }); }); @@ -461,39 +619,71 @@ describe("CredentialGeneratorService", () => { // these tests cannot use the observable tracker because they return // data that cannot be cloned it("returns password metadata", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$("password")); - expect(result).toContain(Generators.password); - expect(result).toContain(Generators.passphrase); + expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy(); }); it("returns username metadata", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$("username")); - expect(result).toContain(Generators.username); + expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); }); it("returns email metadata", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$("email")); - expect(result).toContain(Generators.catchall); - expect(result).toContain(Generators.subaddress); + expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); }); it("returns username and email metadata", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$(["username", "email"])); - expect(result).toContain(Generators.username); - expect(result).toContain(Generators.catchall); - expect(result).toContain(Generators.subaddress); + expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy(); }); // Subsequent tests focus on passwords and passphrases as an example of policy @@ -501,13 +691,21 @@ describe("CredentialGeneratorService", () => { it("enforces the active user's policy", async () => { const policy$ = new BehaviorSubject([passwordOverridePolicy]); policyService.getAll$.mockReturnValue(policy$); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.algorithms$(["password"])); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); - expect(result).toContain(Generators.password); - expect(result).not.toContain(Generators.passphrase); + expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy(); + expect(result.some((a) => a.id === Generators.passphrase.id)).toBeFalsy(); }); it("follows changes to the active user", async () => { @@ -518,7 +716,15 @@ describe("CredentialGeneratorService", () => { await accountService.switchAccount(SomeUser); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const results: any = []; const sub = generator.algorithms$("password").subscribe((r) => results.push(r)); @@ -533,34 +739,50 @@ describe("CredentialGeneratorService", () => { PolicyType.PasswordGenerator, SomeUser, ); - expect(someResult).toContain(Generators.password); - expect(someResult).not.toContain(Generators.passphrase); + expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); + expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); expect(policyService.getAll$).toHaveBeenNthCalledWith( 2, PolicyType.PasswordGenerator, AnotherUser, ); - expect(anotherResult).toContain(Generators.passphrase); - expect(anotherResult).not.toContain(Generators.password); + expect(anotherResult.some((a: any) => a.id === Generators.passphrase.id)).toBeTruthy(); + expect(anotherResult.some((a: any) => a.id === Generators.password.id)).toBeFalsy(); }); it("reads an arbitrary user's settings", async () => { policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const result = await firstValueFrom(generator.algorithms$("password", { userId$ })); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser); - expect(result).toContain(Generators.password); - expect(result).not.toContain(Generators.passphrase); + expect(result.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); + expect(result.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); }); it("follows changes to the arbitrary user", async () => { policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const results: any = []; @@ -572,17 +794,25 @@ describe("CredentialGeneratorService", () => { const [someResult, anotherResult] = results; expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser); - expect(someResult).toContain(Generators.password); - expect(someResult).not.toContain(Generators.passphrase); + expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy(); + expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy(); expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser); - expect(anotherResult).toContain(Generators.passphrase); - expect(anotherResult).not.toContain(Generators.password); + expect(anotherResult.some((a: any) => a.id === Generators.passphrase.id)).toBeTruthy(); + expect(anotherResult.some((a: any) => a.id === Generators.password.id)).toBeFalsy(); }); it("errors when the arbitrary user's stream errors", async () => { policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let error = null; @@ -600,7 +830,15 @@ describe("CredentialGeneratorService", () => { it("completes when the arbitrary user's stream completes", async () => { policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let completed = false; @@ -618,7 +856,15 @@ describe("CredentialGeneratorService", () => { it("ignores repeated arbitrary user emissions", async () => { policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy])); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let count = 0; @@ -642,7 +888,15 @@ describe("CredentialGeneratorService", () => { describe("settings$", () => { it("defaults to the configuration's initial settings if settings aren't found", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -652,7 +906,15 @@ describe("CredentialGeneratorService", () => { it("reads from the active user's configuration-defined storage", async () => { const settings = { foo: "value" }; await stateProvider.setUserState(SettingsKey, settings, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -664,7 +926,15 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, settings, SomeUser); const policy$ = new BehaviorSubject([somePolicy]); policyService.getAll$.mockReturnValue(policy$); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const result = await firstValueFrom(generator.settings$(SomeConfiguration)); @@ -672,7 +942,7 @@ describe("CredentialGeneratorService", () => { }); it("follows changes to the active user", async () => { - // initialize local accound service and state provider because this test is sensitive + // initialize local account service and state provider because this test is sensitive // to some shared data in `FakeAccountService`. const accountService = new FakeAccountService(accounts); const stateProvider = new FakeStateProvider(accountService); @@ -681,7 +951,15 @@ describe("CredentialGeneratorService", () => { const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const results: any = []; const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r)); @@ -698,7 +976,15 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser); const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(AnotherUser).asObservable(); const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ })); @@ -711,7 +997,15 @@ describe("CredentialGeneratorService", () => { await stateProvider.setUserState(SettingsKey, someSettings, SomeUser); const anotherSettings = { foo: "another" }; await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const results: any = []; @@ -730,7 +1024,15 @@ describe("CredentialGeneratorService", () => { it("errors when the arbitrary user's stream errors", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let error = null; @@ -748,7 +1050,15 @@ describe("CredentialGeneratorService", () => { it("completes when the arbitrary user's stream completes", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let completed = false; @@ -766,7 +1076,15 @@ describe("CredentialGeneratorService", () => { it("ignores repeated arbitrary user emissions", async () => { await stateProvider.setUserState(SettingsKey, null, SomeUser); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); let count = 0; @@ -790,7 +1108,15 @@ describe("CredentialGeneratorService", () => { describe("settings", () => { it("writes to the user's state", async () => { const singleUserId$ = new BehaviorSubject(SomeUser).asObservable(); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const subject = await generator.settings(SomeConfiguration, { singleUserId$ }); subject.next({ foo: "next value" }); @@ -803,7 +1129,15 @@ describe("CredentialGeneratorService", () => { it("waits for the user to become available", async () => { const singleUserId = new BehaviorSubject(null); const singleUserId$ = singleUserId.asObservable(); - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); let completed = false; const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => { @@ -821,7 +1155,15 @@ describe("CredentialGeneratorService", () => { describe("policy$", () => { it("creates constraints without policy in effect when there is no policy", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(SomeUser).asObservable(); const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ })); @@ -830,7 +1172,15 @@ describe("CredentialGeneratorService", () => { }); it("creates constraints with policy in effect when there is a policy", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId$ = new BehaviorSubject(SomeUser).asObservable(); const policy$ = new BehaviorSubject([somePolicy]); policyService.getAll$.mockReturnValue(policy$); @@ -841,7 +1191,15 @@ describe("CredentialGeneratorService", () => { }); it("follows policy emissions", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const somePolicySubject = new BehaviorSubject([somePolicy]); @@ -862,7 +1220,15 @@ describe("CredentialGeneratorService", () => { }); it("follows user emissions", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable(); @@ -884,7 +1250,15 @@ describe("CredentialGeneratorService", () => { }); it("errors when the user errors", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); const expectedError = { some: "error" }; @@ -902,7 +1276,15 @@ describe("CredentialGeneratorService", () => { }); it("completes when the user completes", async () => { - const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService); + const generator = new CredentialGeneratorService( + randomizer, + stateProvider, + policyService, + apiService, + i18nService, + encryptService, + cryptoService, + ); const userId = new BehaviorSubject(SomeUser); const userId$ = userId.asObservable(); diff --git a/libs/tools/generator/core/src/services/credential-generator.service.ts b/libs/tools/generator/core/src/services/credential-generator.service.ts index 693ffd654d..a137c153a6 100644 --- a/libs/tools/generator/core/src/services/credential-generator.service.ts +++ b/libs/tools/generator/core/src/services/credential-generator.service.ts @@ -11,38 +11,60 @@ import { ignoreElements, map, Observable, - race, share, skipUntil, switchMap, takeUntil, + takeWhile, withLatestFrom, } from "rxjs"; import { Simplify } from "type-fest"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { OnDependency, SingleUserDependency, + UserBound, UserDependency, } from "@bitwarden/common/tools/dependencies"; -import { isDynamic } from "@bitwarden/common/tools/state/state-constraints-dependency"; +import { IntegrationId, IntegrationMetadata } from "@bitwarden/common/tools/integration"; +import { RestClient } from "@bitwarden/common/tools/integration/rpc"; +import { anyComplete } from "@bitwarden/common/tools/rx"; +import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer"; +import { UserEncryptor } from "@bitwarden/common/tools/state/user-encryptor.abstraction"; +import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor"; import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; +import { UserId } from "@bitwarden/common/types/guid"; import { Randomizer } from "../abstractions"; -import { Generators } from "../data"; +import { + Generators, + getForwarderConfiguration, + Integrations, + toCredentialGeneratorConfiguration, +} from "../data"; import { availableAlgorithms } from "../policies/available-algorithms-policy"; import { mapPolicyToConstraints } from "../rx"; import { CredentialAlgorithm, CredentialCategories, CredentialCategory, - CredentialGeneratorInfo, + AlgorithmInfo, CredentialPreference, + isForwarderIntegration, + ForwarderIntegration, } from "../types"; -import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration"; +import { + CredentialGeneratorConfiguration as Configuration, + CredentialGeneratorInfo, + GeneratorDependencyProvider, +} from "../types/credential-generator-configuration"; import { GeneratorConstraints } from "../types/generator-constraints"; import { PREFERENCES } from "./credential-preferences"; @@ -59,17 +81,33 @@ type Generate$Dependencies = Simplify & Partial; + + integration$?: Observable; }; type Algorithms$Dependencies = Partial; +const OPTIONS_FRAME_SIZE = 512; + export class CredentialGeneratorService { constructor( - private randomizer: Randomizer, - private stateProvider: StateProvider, - private policyService: PolicyService, + private readonly randomizer: Randomizer, + private readonly stateProvider: StateProvider, + private readonly policyService: PolicyService, + private readonly apiService: ApiService, + private readonly i18nService: I18nService, + private readonly encryptService: EncryptService, + private readonly cryptoService: CryptoService, ) {} + private getDependencyProvider(): GeneratorDependencyProvider { + return { + client: new RestClient(this.apiService, this.i18nService), + i18nService: this.i18nService, + randomizer: this.randomizer, + }; + } + // FIXME: the rxjs methods of this service can be a lot more resilient if // `Subjects` are introduced where sharing occurs @@ -84,18 +122,13 @@ export class CredentialGeneratorService { dependencies?: Generate$Dependencies, ) { // instantiate the engine - const engine = configuration.engine.create(this.randomizer); + const engine = configuration.engine.create(this.getDependencyProvider()); // stream blocks until all of these values are received const website$ = dependencies?.website$ ?? new BehaviorSubject(null); const request$ = website$.pipe(map((website) => ({ website }))); const settings$ = this.settings$(configuration, dependencies); - // monitor completion - const requestComplete$ = request$.pipe(ignoreElements(), endWith(true)); - const settingsComplete$ = request$.pipe(ignoreElements(), endWith(true)); - const complete$ = race(requestComplete$, settingsComplete$); - // if on$ triggers before settings are loaded, trigger as soon // as they become available. let readyOn$: Observable = null; @@ -116,7 +149,7 @@ export class CredentialGeneratorService { const generate$ = (readyOn$ ?? settings$).pipe( withLatestFrom(request$, settings$), concatMap(([, request, settings]) => engine.generate(request, settings)), - takeUntil(complete$), + takeUntil(anyComplete([request$, settings$])), ); return generate$; @@ -132,11 +165,11 @@ export class CredentialGeneratorService { algorithms$( category: CredentialCategory, dependencies?: Algorithms$Dependencies, - ): Observable; + ): Observable; algorithms$( category: CredentialCategory[], dependencies?: Algorithms$Dependencies, - ): Observable; + ): Observable; algorithms$( category: CredentialCategory | CredentialCategory[], dependencies?: Algorithms$Dependencies, @@ -163,7 +196,9 @@ export class CredentialGeneratorService { return policies$; }), map((available) => { - const filtered = algorithms.filter((c) => available.has(c.id)); + const filtered = algorithms.filter( + (c) => isForwarderIntegration(c.id) || available.has(c.id), + ); return filtered; }), ); @@ -175,24 +210,79 @@ export class CredentialGeneratorService { * @param category the category or categories of interest * @returns A list containing the requested metadata. */ - algorithms(category: CredentialCategory): CredentialGeneratorInfo[]; - algorithms(category: CredentialCategory[]): CredentialGeneratorInfo[]; - algorithms(category: CredentialCategory | CredentialCategory[]): CredentialGeneratorInfo[] { - const categories = Array.isArray(category) ? category : [category]; + algorithms(category: CredentialCategory): AlgorithmInfo[]; + algorithms(category: CredentialCategory[]): AlgorithmInfo[]; + algorithms(category: CredentialCategory | CredentialCategory[]): AlgorithmInfo[] { + const categories: CredentialCategory[] = Array.isArray(category) ? category : [category]; + const algorithms = categories - .flatMap((c) => CredentialCategories[c]) - .map((c) => (c === "forwarder" ? null : Generators[c])) + .flatMap((c) => CredentialCategories[c] as CredentialAlgorithm[]) + .map((id) => this.algorithm(id)) .filter((info) => info !== null); - return algorithms; + const forwarders = Object.keys(Integrations) + .map((key: keyof typeof Integrations) => { + const forwarder: ForwarderIntegration = { forwarder: Integrations[key].id }; + return this.algorithm(forwarder); + }) + .filter((forwarder) => categories.includes(forwarder.category)); + + return algorithms.concat(forwarders); } /** Look up the metadata for a specific generator algorithm * @param id identifies the algorithm * @returns the requested metadata, or `null` if the metadata wasn't found. */ - algorithm(id: CredentialAlgorithm): CredentialGeneratorInfo { - return (id === "forwarder" ? null : Generators[id]) ?? null; + algorithm(id: CredentialAlgorithm): AlgorithmInfo { + let generator: CredentialGeneratorInfo = null; + let integration: IntegrationMetadata = null; + + if (isForwarderIntegration(id)) { + const forwarderConfig = getForwarderConfiguration(id.forwarder); + integration = forwarderConfig; + + if (forwarderConfig) { + generator = toCredentialGeneratorConfiguration(forwarderConfig); + } + } else { + generator = Generators[id]; + } + + if (!generator) { + throw new Error(`Invalid credential algorithm: ${JSON.stringify(id)}`); + } + + const info: AlgorithmInfo = { + id: generator.id, + category: generator.category, + name: integration ? integration.name : this.i18nService.t(generator.nameKey), + generate: this.i18nService.t(generator.generateKey), + copy: this.i18nService.t(generator.copyKey), + onlyOnRequest: generator.onlyOnRequest, + request: generator.request, + }; + + if (generator.descriptionKey) { + info.description = this.i18nService.t(generator.descriptionKey); + } + + return info; + } + + private encryptor$(userId: UserId) { + const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); + const encryptor$ = this.cryptoService.userKey$(userId).pipe( + // complete when the account locks + takeWhile((key) => !!key), + map((key) => { + const encryptor = new UserKeyEncryptor(userId, this.encryptService, key, packer); + + return { userId, encryptor } satisfies UserBound<"encryptor", UserEncryptor>; + }), + ); + + return encryptor$; } /** Get the settings for the provided configuration @@ -208,27 +298,21 @@ export class CredentialGeneratorService { dependencies?: Settings$Dependencies, ) { const userId$ = dependencies?.userId$ ?? this.stateProvider.activeUserId$; - const completion$ = userId$.pipe(ignoreElements(), endWith(true)); + const constraints$ = this.policy$(configuration, { userId$ }); - const state$ = userId$.pipe( + const settings$ = userId$.pipe( filter((userId) => !!userId), distinctUntilChanged(), switchMap((userId) => { - const state$ = this.stateProvider - .getUserState$(configuration.settings.account, userId) - .pipe(takeUntil(completion$)); - + const state$ = new UserStateSubject( + configuration.settings.account, + (key) => this.stateProvider.getUser(userId, key), + { constraints$, singleUserEncryptor$: this.encryptor$(userId) }, + ); return state$; }), map((settings) => settings ?? structuredClone(configuration.settings.initial)), - ); - - const settings$ = combineLatest([state$, this.policy$(configuration, { userId$ })]).pipe( - map(([settings, policy]) => { - const calibration = isDynamic(policy) ? policy.calibrate(settings) : policy; - const adjusted = calibration.adjust(settings); - return adjusted; - }), + takeUntil(anyComplete(userId$)), ); return settings$; @@ -251,8 +335,11 @@ export class CredentialGeneratorService { ); // FIXME: enforce policy - const state = this.stateProvider.getUser(userId, PREFERENCES); - const subject = new UserStateSubject(state, { ...dependencies }); + const subject = new UserStateSubject( + PREFERENCES, + (key) => this.stateProvider.getUser(userId, key), + { singleUserEncryptor$: this.encryptor$(userId) }, + ); return subject; } @@ -271,10 +358,14 @@ export class CredentialGeneratorService { const userId = await firstValueFrom( dependencies.singleUserId$.pipe(filter((userId) => !!userId)), ); - const state = this.stateProvider.getUser(userId, configuration.settings.account); + const constraints$ = this.policy$(configuration, { userId$: dependencies.singleUserId$ }); - const subject = new UserStateSubject(state, { ...dependencies, constraints$ }); + const subject = new UserStateSubject( + configuration.settings.account, + (key) => this.stateProvider.getUser(userId, key), + { constraints$, singleUserEncryptor$: this.encryptor$(userId) }, + ); return subject; } diff --git a/libs/tools/generator/core/src/types/credential-generator-configuration.ts b/libs/tools/generator/core/src/types/credential-generator-configuration.ts index 8302450d44..1798323ec6 100644 --- a/libs/tools/generator/core/src/types/credential-generator-configuration.ts +++ b/libs/tools/generator/core/src/types/credential-generator-configuration.ts @@ -1,4 +1,7 @@ +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { UserKeyDefinition } from "@bitwarden/common/platform/state"; +import { RestClient } from "@bitwarden/common/tools/integration/rpc"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { Constraints } from "@bitwarden/common/tools/types"; import { Randomizer } from "../abstractions"; @@ -6,9 +9,58 @@ import { CredentialAlgorithm, CredentialCategory, PolicyConfiguration } from ".. import { CredentialGenerator } from "./credential-generator"; +export type GeneratorDependencyProvider = { + randomizer: Randomizer; + client: RestClient; + i18nService: I18nService; +}; + +export type AlgorithmInfo = { + /** Uniquely identifies the credential configuration + * @example + * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` + * // to pattern test whether the credential describes a forwarder algorithm + * const meta : CredentialGeneratorInfo = // ... + * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; + */ + id: CredentialAlgorithm; + + /** The kind of credential generated by this configuration */ + category: CredentialCategory; + + /** Localized algorithm name */ + name: string; + + /* Localized generate button label */ + generate: string; + + /* Localized copy button label */ + copy: string; + + /** Localized algorithm description */ + description?: string; + + /** When true, credential generation must be explicitly requested. + * @remarks this property is useful when credential generation + * carries side effects, such as configuring a service external + * to Bitwarden. + */ + onlyOnRequest: boolean; + + /** Well-known fields to display on the options panel or collect from the environment. + * @remarks: at present, this is only used by forwarders + */ + request: readonly string[]; +}; + /** Credential generator metadata common across credential generators */ export type CredentialGeneratorInfo = { /** Uniquely identifies the credential configuration + * @example + * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` + * // to pattern test whether the credential describes a forwarder algorithm + * const meta : CredentialGeneratorInfo = // ... + * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; */ id: CredentialAlgorithm; @@ -21,15 +73,32 @@ export type CredentialGeneratorInfo = { /** Key used to localize the credential description in the I18nService */ descriptionKey?: string; + /* Localized generate button label */ + generateKey: string; + + /* Localized copy button label */ + copyKey: string; + /** When true, credential generation must be explicitly requested. * @remarks this property is useful when credential generation * carries side effects, such as configuring a service external * to Bitwarden. */ onlyOnRequest: boolean; + + /** Well-known fields to display on the options panel or collect from the environment. + * @remarks: at present, this is only used by forwarders + */ + request: readonly string[]; }; -/** Credential generator metadata that relies upon typed setting and policy definitions. */ +/** Credential generator metadata that relies upon typed setting and policy definitions. + * @example + * // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)` + * // to pattern test whether the credential describes a forwarder algorithm + * const meta : CredentialGeneratorInfo = // ... + * const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {}; + */ export type CredentialGeneratorConfiguration = CredentialGeneratorInfo & { /** An algorithm that generates credentials when ran. */ engine: { @@ -40,7 +109,7 @@ export type CredentialGeneratorConfiguration = CredentialGener // the credential generator, but engine configurations should return // the underlying type. `create` may be able to do double-duty w/ an // engine definition if `CredentialGenerator` can be made covariant. - create: (randomizer: Randomizer) => CredentialGenerator; + create: (randomizer: GeneratorDependencyProvider) => CredentialGenerator; }; /** Defines the stored parameters for credential generation */ settings: { @@ -51,7 +120,10 @@ export type CredentialGeneratorConfiguration = CredentialGener constraints: Constraints; /** storage location for account-global settings */ - account: UserKeyDefinition; + account: UserKeyDefinition | ObjectKey; + + /** storage location for *plaintext* settings imports */ + import?: UserKeyDefinition | ObjectKey, Settings>; }; /** defines how to construct policy for this settings instance */ diff --git a/libs/tools/generator/core/src/types/generator-type.ts b/libs/tools/generator/core/src/types/generator-type.ts index 59727fb98f..5b74d17fa4 100644 --- a/libs/tools/generator/core/src/types/generator-type.ts +++ b/libs/tools/generator/core/src/types/generator-type.ts @@ -1,3 +1,5 @@ +import { IntegrationId } from "@bitwarden/common/tools/integration"; + import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "../data/generator-types"; /** A type of password that may be generated by the credential generator. */ @@ -9,8 +11,31 @@ export type UsernameAlgorithm = (typeof UsernameAlgorithms)[number]; /** A type of email address that may be generated by the credential generator. */ export type EmailAlgorithm = (typeof EmailAlgorithms)[number]; +export type ForwarderIntegration = { forwarder: IntegrationId }; + +/** Returns true when the input algorithm is a forwarder integration. */ +export function isForwarderIntegration( + algorithm: CredentialAlgorithm, +): algorithm is ForwarderIntegration { + return algorithm && typeof algorithm === "object" && "forwarder" in algorithm; +} + +export function isSameAlgorithm(lhs: CredentialAlgorithm, rhs: CredentialAlgorithm) { + if (lhs === rhs) { + return true; + } else if (isForwarderIntegration(lhs) && isForwarderIntegration(rhs)) { + return lhs.forwarder === rhs.forwarder; + } else { + return false; + } +} + /** A type of credential that may be generated by the credential generator. */ -export type CredentialAlgorithm = PasswordAlgorithm | UsernameAlgorithm | EmailAlgorithm; +export type CredentialAlgorithm = + | PasswordAlgorithm + | UsernameAlgorithm + | EmailAlgorithm + | ForwarderIntegration; /** Compound credential types supported by the credential generator. */ export const CredentialCategories = Object.freeze({ @@ -21,7 +46,7 @@ export const CredentialCategories = Object.freeze({ username: UsernameAlgorithms as Readonly, /** Lists algorithms in the "email" credential category */ - email: EmailAlgorithms as Readonly, + email: EmailAlgorithms as Readonly<(EmailAlgorithm | ForwarderIntegration)[]>, }); /** Returns true when the input algorithm is a password algorithm. */ @@ -40,7 +65,7 @@ export function isUsernameAlgorithm( /** Returns true when the input algorithm is an email algorithm. */ export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm { - return EmailAlgorithms.includes(algorithm as any); + return EmailAlgorithms.includes(algorithm as any) || isForwarderIntegration(algorithm); } /** A type of compound credential that may be generated by the credential generator. */ diff --git a/libs/tools/generator/core/src/types/index.ts b/libs/tools/generator/core/src/types/index.ts index 884d976007..48272cbf60 100644 --- a/libs/tools/generator/core/src/types/index.ts +++ b/libs/tools/generator/core/src/types/index.ts @@ -1,4 +1,4 @@ -import { CredentialAlgorithm, PasswordAlgorithm } from "./generator-type"; +import { EmailAlgorithm, PasswordAlgorithm, UsernameAlgorithm } from "./generator-type"; export * from "./boundary"; export * from "./catchall-generator-options"; @@ -22,7 +22,7 @@ export * from "./word-options"; /** Provided for backwards compatibility only. * @deprecated Use one of the Algorithm types instead. */ -export type GeneratorType = CredentialAlgorithm; +export type GeneratorType = PasswordAlgorithm | UsernameAlgorithm | EmailAlgorithm; /** Provided for backwards compatibility only. * @deprecated Use one of the Algorithm types instead. diff --git a/libs/tools/send/send-ui/src/send-form/send-form.module.ts b/libs/tools/send/send-ui/src/send-form/send-form.module.ts index 99db65807a..df10b56391 100644 --- a/libs/tools/send/send-ui/src/send-form/send-form.module.ts +++ b/libs/tools/send/send-ui/src/send-form/send-form.module.ts @@ -2,8 +2,11 @@ import { NgModule } from "@angular/core"; import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service"; +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { createRandomizer, @@ -32,7 +35,15 @@ const RANDOMIZER = new SafeInjectionToken("Randomizer"); safeProvider({ useClass: CredentialGeneratorService, provide: CredentialGeneratorService, - deps: [RANDOMIZER, StateProvider, PolicyService], + deps: [ + RANDOMIZER, + StateProvider, + PolicyService, + ApiService, + I18nService, + EncryptService, + CryptoService, + ], }), ], exports: [SendFormComponent],