From 19c51ca31e69de26e956014fb8a26b3c5a491d6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Wed, 26 Feb 2025 15:26:29 -0500 Subject: [PATCH] introduce extension service --- libs/common/src/tools/dependencies.ts | 2 +- .../tools/extension/extension.service.spec.ts | 0 .../src/tools/extension/extension.service.ts | 62 +++++ libs/common/src/tools/extension/type.ts | 29 +++ libs/common/src/tools/extension/util.spec.ts | 54 ++++ libs/common/src/tools/extension/util.ts | 33 +++ .../src/tools/integration/integration-id.ts | 2 +- .../tools/integration/integration-metadata.ts | 4 +- libs/common/src/tools/log/factory.ts | 12 +- libs/common/src/tools/providers.ts | 16 ++ .../src/tools/state/user-state-subject.ts | 6 +- .../generator/core/src/data/integrations.ts | 8 +- .../generator/core/src/integration/addy-io.ts | 3 +- .../core/src/integration/duck-duck-go.ts | 3 +- .../core/src/integration/fastmail.ts | 3 +- .../core/src/integration/firefox-relay.ts | 3 +- .../core/src/integration/forward-email.ts | 3 +- .../core/src/integration/simple-login.ts | 3 +- .../core/src/metadata/algorithm-metadata.ts | 16 +- .../core/src/metadata/email/catchall.spec.ts | 6 +- .../core/src/metadata/email/forwarder.ts | 79 +++++- .../src/metadata/email/plus-address.spec.ts | 6 +- .../generator/core/src/metadata/index.ts | 11 + .../metadata/password/eff-word-list.spec.ts | 28 +- .../metadata/password/random-password.spec.ts | 12 +- .../core/src/metadata/profile-metadata.ts | 12 +- .../metadata/username/eff-word-list.spec.ts | 6 +- .../generator/core/src/metadata/util.spec.ts | 37 ++- .../tools/generator/core/src/metadata/util.ts | 12 +- .../policies/available-algorithms-policy.ts | 32 ++- .../services/generator-metadata-provider.ts | 244 ++++++++++++++++++ .../core/src/types/generator-type.ts | 8 +- 32 files changed, 679 insertions(+), 76 deletions(-) create mode 100644 libs/common/src/tools/extension/extension.service.spec.ts create mode 100644 libs/common/src/tools/extension/extension.service.ts create mode 100644 libs/common/src/tools/extension/util.spec.ts create mode 100644 libs/common/src/tools/extension/util.ts create mode 100644 libs/common/src/tools/providers.ts create mode 100644 libs/tools/generator/core/src/metadata/index.ts create mode 100644 libs/tools/generator/core/src/services/generator-metadata-provider.ts diff --git a/libs/common/src/tools/dependencies.ts b/libs/common/src/tools/dependencies.ts index 962865c5bbc..5aeb79023c9 100644 --- a/libs/common/src/tools/dependencies.ts +++ b/libs/common/src/tools/dependencies.ts @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "../types/guid"; /** error emitted when the `SingleUserDependency` changes Ids */ export type UserChangedError = { diff --git a/libs/common/src/tools/extension/extension.service.spec.ts b/libs/common/src/tools/extension/extension.service.spec.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/libs/common/src/tools/extension/extension.service.ts b/libs/common/src/tools/extension/extension.service.ts new file mode 100644 index 00000000000..f673df88aad --- /dev/null +++ b/libs/common/src/tools/extension/extension.service.ts @@ -0,0 +1,62 @@ +import { EMPTY, Observable, defer, of, shareReplay } from "rxjs"; + +import { Account } from "../../auth/abstractions/account.service"; +import { BoundDependency } from "../dependencies"; +import { SemanticLogger } from "../log"; +import { UserStateSubject } from "../state/user-state-subject"; +import { UserStateSubjectDependencyProvider } from "../state/user-state-subject-dependency-provider"; + +import { ExtensionRegistry } from "./extension-registry.abstraction"; +import { ExtensionSite } from "./extension-site"; +import { ExtensionProfileMetadata, SiteId, VendorId } from "./type"; +import { toObjectKey } from "./util"; + +/** Provides configuration and storage support for Bitwarden client extensions. + * These extensions integrate 3rd party services into Bitwarden. + */ +export class ExtensionService { + constructor( + private registry: ExtensionRegistry, + private readonly providers: UserStateSubjectDependencyProvider, + ) { + this.log = providers.log({ + type: "ExtensionService", + }); + } + + private log: SemanticLogger; + + settings( + profile: ExtensionProfileMetadata, + vendor: VendorId, + dependencies: BoundDependency<"account", Account>, + ): UserStateSubject { + const metadata = this.registry.extension(profile.site, vendor); + if (!metadata) { + this.log.panic({ site: profile.site as string, vendor }, "extension not defined"); + } + + const key = toObjectKey(profile, metadata); + const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true })); + const subject = new UserStateSubject(key, this.providers, { account$ }); + + return subject; + } + + site(site: SiteId) { + return this.registry.build(site); + } + + /** Look up extension metadata for a site. + * @param site defines the site to retrieve. + * @returns an observable that emits the extension sites available at the + * moment of subscription and then completes. If the extension site is not + * available, the observable completes without emitting. + */ + site$(site: SiteId): Observable { + return defer(() => { + const extensions = this.registry.build(site); + return extensions ? of(extensions) : EMPTY; + }); + } +} diff --git a/libs/common/src/tools/extension/type.ts b/libs/common/src/tools/extension/type.ts index f37d4ff8e53..e61e45c7fd5 100644 --- a/libs/common/src/tools/extension/type.ts +++ b/libs/common/src/tools/extension/type.ts @@ -1,5 +1,7 @@ import { Opaque } from "type-fest"; +import { ObjectKey } from "../state/object-key"; + import { Site, Field, Permission } from "./data"; /** well-known name for a feature extensible through an extension. */ @@ -17,6 +19,11 @@ export type ExtensionId = { site: SiteId; vendor: VendorId }; /** Permission levels for metadata. */ export type ExtensionPermission = keyof typeof Permission; +/** The preferred vendor to use at each site. */ +export type ExtensionPreferences = { + [key in SiteId]?: { vendor: VendorId; updated: Date }; +}; + /** The capabilities and descriptive content for an extension */ export type SiteMetadata = { /** Uniquely identifies the extension site. */ @@ -107,3 +114,25 @@ export type ExtensionSet = */ all: true; }; + +export type ExtensionStorageKey = Omit< + ObjectKey, + "target" | "state" | "format" | "classifier" +>; + +/** Extension profiles encapsulate data storage using the extension system. + */ +export type ExtensionProfileMetadata = { + /** distinguishes profile metadata types */ + type: "extension"; + + /** The extension site described by this metadata */ + site: Site; + + /** persistent storage location; `storage.key` is used to construct + * the extension key in the format `${extension.site}.${extension.vendor}.${storage.key}`, + * where `extension.`-prefixed fields are read from extension metadata. Extension + * settings always use the "classified" format and keep all fields private. + */ + storage: ExtensionStorageKey; +}; diff --git a/libs/common/src/tools/extension/util.spec.ts b/libs/common/src/tools/extension/util.spec.ts new file mode 100644 index 00000000000..f6f3341a986 --- /dev/null +++ b/libs/common/src/tools/extension/util.spec.ts @@ -0,0 +1,54 @@ +import { EXTENSION_DISK } from "../../platform/state"; +import { PrivateClassifier } from "../private-classifier"; +import { deepFreeze } from "../util"; + +import { Site } from "./data"; +import { ExtensionMetadata, ExtensionProfileMetadata } from "./type"; +import { toObjectKey } from "./util"; +import { Bitwarden } from "./vendor/bitwarden"; + +const ExampleProfile: ExtensionProfileMetadata = deepFreeze({ + type: "extension", + site: "forwarder", + storage: { + key: "example", + options: { + clearOn: [], + deserializer: (value) => value as any, + }, + initial: {}, + frame: 1, + }, +}); + +const ExampleMetadata: ExtensionMetadata = { + site: { id: Site.forwarder, availableFields: [] }, + product: { vendor: Bitwarden }, + host: { authentication: true, selfHost: "maybe", baseUrl: "http://example.com" }, + requestedFields: [], +}; + +describe("toObjectKey", () => { + it("sets static fields", () => { + const result = toObjectKey(ExampleProfile, ExampleMetadata); + + expect(result.target).toEqual("object"); + expect(result.format).toEqual("classified"); + expect(result.state).toBe(EXTENSION_DISK); + expect(result.classifier).toBeInstanceOf(PrivateClassifier); + }); + + it("creates a dynamic object key", () => { + const result = toObjectKey(ExampleProfile, ExampleMetadata); + + expect(result.key).toEqual("forwarder.bitwarden.example"); + }); + + it("copies the profile storage metadata", () => { + const result = toObjectKey(ExampleProfile, ExampleMetadata); + + expect(result.frame).toEqual(ExampleProfile.storage.frame); + expect(result.options).toBe(ExampleProfile.storage.options); + expect(result.initial).toBe(ExampleProfile.storage.initial); + }); +}); diff --git a/libs/common/src/tools/extension/util.ts b/libs/common/src/tools/extension/util.ts new file mode 100644 index 00000000000..caf2f26afdd --- /dev/null +++ b/libs/common/src/tools/extension/util.ts @@ -0,0 +1,33 @@ +import { EXTENSION_DISK } from "../../platform/state"; +import { PrivateClassifier } from "../private-classifier"; +import { Classifier } from "../state/classifier"; +import { ObjectKey } from "../state/object-key"; + +import { ExtensionMetadata, ExtensionProfileMetadata, SiteId } from "./type"; + +/** Binds an extension profile to an extension site */ +export function toObjectKey( + profile: ExtensionProfileMetadata, + extension: ExtensionMetadata, +) { + // FIXME: eliminate this cast + const classifier = new PrivateClassifier() as Classifier< + Settings, + Record, + Settings + >; + + const result: ObjectKey = { + // copy storage to retain extensibility + ...profile.storage, + + // fields controlled by the extension system override those in the profile + target: "object", + key: `${extension.site.id}.${extension.product.vendor.id}.${profile.storage.key}`, + state: EXTENSION_DISK, + classifier, + format: "classified", + }; + + return result; +} diff --git a/libs/common/src/tools/integration/integration-id.ts b/libs/common/src/tools/integration/integration-id.ts index a15db143ee1..a3d83aba46e 100644 --- a/libs/common/src/tools/integration/integration-id.ts +++ b/libs/common/src/tools/integration/integration-id.ts @@ -10,4 +10,4 @@ export const IntegrationIds = [ ] as const; /** Identifies a vendor integrated into bitwarden */ -export type IntegrationId = Opaque<(typeof IntegrationIds)[number], "IntegrationId">; +export type IntegrationId = Opaque; diff --git a/libs/common/src/tools/integration/integration-metadata.ts b/libs/common/src/tools/integration/integration-metadata.ts index e460aae828c..2073b16feb0 100644 --- a/libs/common/src/tools/integration/integration-metadata.ts +++ b/libs/common/src/tools/integration/integration-metadata.ts @@ -1,10 +1,12 @@ +import { VendorId } from "../extension"; + import { ExtensionPointId } from "./extension-point-id"; import { IntegrationId } from "./integration-id"; /** The capabilities and descriptive content for an integration */ export type IntegrationMetadata = { /** Uniquely identifies the integrator. */ - id: IntegrationId; + id: IntegrationId & VendorId; /** Brand name of the integrator. */ name: string; diff --git a/libs/common/src/tools/log/factory.ts b/libs/common/src/tools/log/factory.ts index f011c57187a..f8abc4d2240 100644 --- a/libs/common/src/tools/log/factory.ts +++ b/libs/common/src/tools/log/factory.ts @@ -6,6 +6,9 @@ import { DefaultSemanticLogger } from "./default-semantic-logger"; import { DisabledSemanticLogger } from "./disabled-semantic-logger"; import { SemanticLogger } from "./semantic-logger.abstraction"; +/** A type for injection of a log provider */ +export type LogProvider = (context: Jsonify) => SemanticLogger; + /** Instantiates a semantic logger that emits nothing when a message * is logged. * @param _context a static payload that is cloned when the logger @@ -25,8 +28,11 @@ export function disabledSemanticLoggerProvider( * @param settings specializes how the semantic logger functions. * If this is omitted, the logger suppresses debug messages. */ -export function consoleSemanticLoggerProvider(logger: LogService): SemanticLogger { - return new DefaultSemanticLogger(logger, {}); +export function consoleSemanticLoggerProvider( + logger: LogService, + context: Jsonify, +): SemanticLogger { + return new DefaultSemanticLogger(logger, context); } /** Instantiates a semantic logger that emits logs to the console. @@ -42,7 +48,7 @@ export function ifEnabledSemanticLoggerProvider( context: Jsonify, ) { if (enable) { - return new DefaultSemanticLogger(logger, context); + return consoleSemanticLoggerProvider(logger, context); } else { return disabledSemanticLoggerProvider(context); } diff --git a/libs/common/src/tools/providers.ts b/libs/common/src/tools/providers.ts new file mode 100644 index 00000000000..a22a22addc5 --- /dev/null +++ b/libs/common/src/tools/providers.ts @@ -0,0 +1,16 @@ +import { PolicyService } from "../admin-console/abstractions/policy/policy.service.abstraction"; + +import { ExtensionService } from "./extension/extension.service"; +import { LogProvider } from "./log"; + +/** Provides access to commonly-used cross-cutting services. */ +export type SystemServiceProvider = { + /** Policy configured by the administrative console */ + readonly policy: PolicyService; + + /** Client extension metadata and profile access */ + readonly extension: ExtensionService; + + /** Event monitoring and diagnostic interfaces */ + readonly log: LogProvider; +}; diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 500a96238db..b643b642154 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -81,7 +81,7 @@ const DEFAULT_FRAME_SIZE = 32; export class UserStateSubject< State extends object, Secret = State, - Disclosed = never, + Disclosed = Record, Dependencies = null, > extends Observable @@ -243,7 +243,7 @@ export class UserStateSubject< // `init$` becomes the accumulator for `scan` init$.pipe( first(), - map((init) => [init, null] as const), + map((init) => [init, null] as [State, Dependencies]), ), input$.pipe( map((constrained) => constrained.state), @@ -256,7 +256,7 @@ export class UserStateSubject< if (shouldUpdate) { // actual update const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending; - return [next, dependencies]; + return [next, dependencies] as const; } else { // false update this.log.debug("shouldUpdate prevented write"); diff --git a/libs/tools/generator/core/src/data/integrations.ts b/libs/tools/generator/core/src/data/integrations.ts index 21c883cae02..ffe4676fcd7 100644 --- a/libs/tools/generator/core/src/data/integrations.ts +++ b/libs/tools/generator/core/src/data/integrations.ts @@ -1,5 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings } from "@bitwarden/common/tools/integration/rpc"; @@ -29,8 +30,11 @@ export const Integrations = Object.freeze({ const integrations = new Map(Object.values(Integrations).map((i) => [i.id, i])); -export function getForwarderConfiguration(id: IntegrationId): ForwarderConfiguration { - const maybeForwarder = integrations.get(id); +export function getForwarderConfiguration( + id: IntegrationId | VendorId, +): ForwarderConfiguration { + // these casts are for compatibility; `IntegrationId` is the old form of `VendorId` + const maybeForwarder = integrations.get(id as string as IntegrationId & VendorId); if (maybeForwarder && "forwarder" in maybeForwarder) { return maybeForwarder as ForwarderConfiguration; diff --git a/libs/tools/generator/core/src/integration/addy-io.ts b/libs/tools/generator/core/src/integration/addy-io.ts index d9f2b9f121d..631c5fdb510 100644 --- a/libs/tools/generator/core/src/integration/addy-io.ts +++ b/libs/tools/generator/core/src/integration/addy-io.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, @@ -100,7 +101,7 @@ const forwarder = Object.freeze({ export const AddyIo = Object.freeze({ // integration - id: "anonaddy" as IntegrationId, + id: "anonaddy" as IntegrationId & VendorId, name: "Addy.io", extends: ["forwarder"], 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 0bcdd560503..d2bd6173a14 100644 --- a/libs/tools/generator/core/src/integration/duck-duck-go.ts +++ b/libs/tools/generator/core/src/integration/duck-duck-go.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; 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"; @@ -89,7 +90,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const DuckDuckGo = Object.freeze({ - id: "duckduckgo" as IntegrationId, + id: "duckduckgo" as IntegrationId & VendorId, name: "DuckDuckGo", baseUrl: "https://quack.duckduckgo.com/api", selfHost: "never", diff --git a/libs/tools/generator/core/src/integration/fastmail.ts b/libs/tools/generator/core/src/integration/fastmail.ts index 69b908badc9..bfde1aa70f5 100644 --- a/libs/tools/generator/core/src/integration/fastmail.ts +++ b/libs/tools/generator/core/src/integration/fastmail.ts @@ -5,6 +5,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; 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"; @@ -159,7 +160,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const Fastmail = Object.freeze({ - id: "fastmail" as IntegrationId, + id: "fastmail" as IntegrationId & VendorId, name: "Fastmail", baseUrl: "https://api.fastmail.com", selfHost: "maybe", diff --git a/libs/tools/generator/core/src/integration/firefox-relay.ts b/libs/tools/generator/core/src/integration/firefox-relay.ts index ae65611905f..9f40a3631ff 100644 --- a/libs/tools/generator/core/src/integration/firefox-relay.ts +++ b/libs/tools/generator/core/src/integration/firefox-relay.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; 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"; @@ -97,7 +98,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const FirefoxRelay = Object.freeze({ - id: "firefoxrelay" as IntegrationId, + id: "firefoxrelay" as IntegrationId & VendorId, name: "Firefox Relay", baseUrl: "https://relay.firefox.com/api", selfHost: "never", diff --git a/libs/tools/generator/core/src/integration/forward-email.ts b/libs/tools/generator/core/src/integration/forward-email.ts index d67b8d588bf..34b4602b94b 100644 --- a/libs/tools/generator/core/src/integration/forward-email.ts +++ b/libs/tools/generator/core/src/integration/forward-email.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; 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"; @@ -101,7 +102,7 @@ const forwarder = Object.freeze({ export const ForwardEmail = Object.freeze({ // integration metadata - id: "forwardemail" as IntegrationId, + id: "forwardemail" as IntegrationId & VendorId, name: "Forward Email", extends: ["forwarder"], diff --git a/libs/tools/generator/core/src/integration/simple-login.ts b/libs/tools/generator/core/src/integration/simple-login.ts index 1581f3861f5..efbac69cec2 100644 --- a/libs/tools/generator/core/src/integration/simple-login.ts +++ b/libs/tools/generator/core/src/integration/simple-login.ts @@ -3,6 +3,7 @@ import { GENERATOR_MEMORY, UserKeyDefinition, } from "@bitwarden/common/platform/state"; +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration"; import { ApiSettings, @@ -103,7 +104,7 @@ const forwarder = Object.freeze({ // integration-wide configuration export const SimpleLogin = Object.freeze({ - id: "simplelogin" as IntegrationId, + id: "simplelogin" as IntegrationId & VendorId, name: "SimpleLogin", selfHost: "maybe", extends: ["forwarder"], diff --git a/libs/tools/generator/core/src/metadata/algorithm-metadata.ts b/libs/tools/generator/core/src/metadata/algorithm-metadata.ts index f776dd76e54..c07deef5535 100644 --- a/libs/tools/generator/core/src/metadata/algorithm-metadata.ts +++ b/libs/tools/generator/core/src/metadata/algorithm-metadata.ts @@ -1,5 +1,7 @@ import { CredentialAlgorithm, CredentialType } from "./type"; +type I18nKeyOrLiteral = string | { literal: string }; + /** Credential generator metadata common across credential generators */ export type AlgorithmMetadata = { /** Uniquely identifies the credential configuration @@ -23,25 +25,25 @@ export type AlgorithmMetadata = { /** Localization keys */ i18nKeys: { /** descriptive name of the algorithm */ - name: string; + name: I18nKeyOrLiteral; /** explanatory text for the algorithm */ - description?: string; + description?: I18nKeyOrLiteral; /** labels the generate action */ - generateCredential: string; + generateCredential: I18nKeyOrLiteral; /** message informing users when the generator produces a new credential */ - credentialGenerated: string; + credentialGenerated: I18nKeyOrLiteral; /* labels the action that assigns a generated value to a domain object */ - useCredential: string; + useCredential: I18nKeyOrLiteral; /** labels the generated output */ - credentialType: string; + credentialType: I18nKeyOrLiteral; /** labels the copy output action */ - copyCredential: string; + copyCredential: I18nKeyOrLiteral; }; /** fine-tunings for generator user experiences */ diff --git a/libs/tools/generator/core/src/metadata/email/catchall.spec.ts b/libs/tools/generator/core/src/metadata/email/catchall.spec.ts index f63f141842c..d6cc1795e0b 100644 --- a/libs/tools/generator/core/src/metadata/email/catchall.spec.ts +++ b/libs/tools/generator/core/src/metadata/email/catchall.spec.ts @@ -19,11 +19,13 @@ describe("email - catchall generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata = null!; beforeEach(() => { const profile = catchall.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + throw new Error("this branch should never run"); } }); diff --git a/libs/tools/generator/core/src/metadata/email/forwarder.ts b/libs/tools/generator/core/src/metadata/email/forwarder.ts index 1dfc219d466..f4f150f33fa 100644 --- a/libs/tools/generator/core/src/metadata/email/forwarder.ts +++ b/libs/tools/generator/core/src/metadata/email/forwarder.ts @@ -1,4 +1,75 @@ -// Forwarders are pending integration with the extension API -// -// They use the 300-block of weights and derive their metadata -// using logic similar to `toCredentialGeneratorConfiguration` +import { ExtensionMetadata, ExtensionStorageKey } from "@bitwarden/common/tools/extension/type"; +import { SelfHostedApiSettings } from "@bitwarden/common/tools/integration/rpc"; +import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; + +import { getForwarderConfiguration } from "../../data"; +import { EmailDomainSettings, EmailPrefixSettings } from "../../engine"; +import { Forwarder } from "../../engine/forwarder"; +import { GeneratorDependencyProvider } from "../../types"; +import { Profile, Type } from "../data"; +import { GeneratorMetadata } from "../generator-metadata"; +import { ForwarderProfileMetadata } from "../profile-metadata"; + +// These options are used by all forwarders; each forwarder uses a different set, +// as defined by `GeneratorMetadata.capabilities.fields`. +type ForwarderOptions = Partial; + +// update the extension metadata +export function toForwarderMetadata( + extension: ExtensionMetadata, +): GeneratorMetadata { + if (extension.site.id !== "forwarder") { + throw new Error( + `expected forwarder extension; received ${extension.site.id} (${extension.product.vendor.id})`, + ); + } + + const name = { literal: extension.product.name ?? extension.product.vendor.name }; + + const generator: GeneratorMetadata = { + id: { forwarder: extension.product.vendor.id }, + category: Type.email, + weight: 300, + i18nKeys: { + name, + description: "forwardedEmailDesc", + generateCredential: "generateEmail", + credentialGenerated: "emailGenerated", + useCredential: "useThisEmail", + credentialType: "email", + copyCredential: "copyEmail", + }, + capabilities: { + autogenerate: false, + fields: [...extension.requestedFields], + }, + engine: { + create(dependencies: GeneratorDependencyProvider) { + const config = getForwarderConfiguration(extension.product.vendor.id); + return new Forwarder(config, dependencies.client, dependencies.i18nService); + }, + }, + profiles: { + [Profile.account]: { + type: "extension", + site: "forwarder", + storage: { + key: "forwarder", + frame: 512, + options: { + deserializer: (value) => value, + clearOn: ["logout"], + }, + } satisfies ExtensionStorageKey, + constraints: { + default: {}, + create() { + return new IdentityConstraint(); + }, + }, + } satisfies ForwarderProfileMetadata, + }, + }; + + return generator; +} diff --git a/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts b/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts index 2ac7645ed30..063cb71c23a 100644 --- a/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts +++ b/libs/tools/generator/core/src/metadata/email/plus-address.spec.ts @@ -19,11 +19,13 @@ describe("email - plus address generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata = null!; beforeEach(() => { const profile = plusAddress.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + throw new Error("this branch should never run"); } }); diff --git a/libs/tools/generator/core/src/metadata/index.ts b/libs/tools/generator/core/src/metadata/index.ts new file mode 100644 index 00000000000..4dce92b58f6 --- /dev/null +++ b/libs/tools/generator/core/src/metadata/index.ts @@ -0,0 +1,11 @@ +import { AlgorithmsByType as ABT } from "./data"; +import { CredentialType, CredentialAlgorithm } from "./type"; + +export const AlgorithmsByType: Record> = ABT; + +export { Profile, Type } from "./data"; +export { toForwarderMetadata } from "./email/forwarder"; +export { GeneratorMetadata } from "./generator-metadata"; +export { ProfileContext, CoreProfileMetadata, ProfileMetadata } from "./profile-metadata"; +export { GeneratorProfile, CredentialAlgorithm, CredentialType } from "./type"; +export { isForwarderProfile, toVendorId, isForwarderExtensionId } from "./util"; diff --git a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts index 57961a60033..e02d63d3d59 100644 --- a/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts +++ b/libs/tools/generator/core/src/metadata/password/eff-word-list.spec.ts @@ -22,19 +22,21 @@ describe("password - eff words generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata | null = null; beforeEach(() => { const profile = effPassphrase.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + accountProfile = null; } }); describe("storage.options.deserializer", () => { it("returns its input", () => { - const value: PassphraseGenerationOptions = { ...accountProfile.storage.initial }; + const value: PassphraseGenerationOptions = { ...accountProfile!.storage.initial }; - const result = accountProfile.storage.options.deserializer(value); + const result = accountProfile!.storage.options.deserializer(value); expect(result).toBe(value); }); @@ -46,15 +48,15 @@ describe("password - eff words generator metadata", () => { // enclosed behaviors change. it("creates a passphrase policy constraints", () => { - const context = { defaultConstraints: accountProfile.constraints.default }; + const context = { defaultConstraints: accountProfile!.constraints.default }; - const constraints = accountProfile.constraints.create([], context); + const constraints = accountProfile!.constraints.create([], context); expect(constraints).toBeInstanceOf(PassphrasePolicyConstraints); }); it("forwards the policy to the constraints", () => { - const context = { defaultConstraints: accountProfile.constraints.default }; + const context = { defaultConstraints: accountProfile!.constraints.default }; const policies = [ { type: PolicyType.PasswordGenerator, @@ -66,13 +68,13 @@ describe("password - eff words generator metadata", () => { }, ] as Policy[]; - const constraints = accountProfile.constraints.create(policies, context); + const constraints = accountProfile!.constraints.create(policies, context); - expect(constraints.constraints.numWords.min).toEqual(6); + expect(constraints.constraints.numWords?.min).toEqual(6); }); it("combines multiple policies in the constraints", () => { - const context = { defaultConstraints: accountProfile.constraints.default }; + const context = { defaultConstraints: accountProfile!.constraints.default }; const policies = [ { type: PolicyType.PasswordGenerator, @@ -92,10 +94,10 @@ describe("password - eff words generator metadata", () => { }, ] as Policy[]; - const constraints = accountProfile.constraints.create(policies, context); + const constraints = accountProfile!.constraints.create(policies, context); - expect(constraints.constraints.numWords.min).toEqual(6); - expect(constraints.constraints.capitalize.requiredValue).toEqual(true); + expect(constraints.constraints.numWords?.min).toEqual(6); + expect(constraints.constraints.capitalize?.requiredValue).toEqual(true); }); }); }); diff --git a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts index d91ceaac248..9e38c50ee2a 100644 --- a/libs/tools/generator/core/src/metadata/password/random-password.spec.ts +++ b/libs/tools/generator/core/src/metadata/password/random-password.spec.ts @@ -22,11 +22,13 @@ describe("password - characters generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata = null!; beforeEach(() => { const profile = password.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + throw new Error("this branch should never run"); } }); @@ -69,7 +71,7 @@ describe("password - characters generator metadata", () => { const constraints = accountProfile.constraints.create(policies, context); - expect(constraints.constraints.length.min).toEqual(10); + expect(constraints.constraints.length?.min).toEqual(10); }); it("combines multiple policies in the constraints", () => { @@ -97,8 +99,8 @@ describe("password - characters generator metadata", () => { const constraints = accountProfile.constraints.create(policies, context); - expect(constraints.constraints.length.min).toEqual(14); - expect(constraints.constraints.special.requiredValue).toEqual(true); + expect(constraints.constraints.length?.min).toEqual(14); + expect(constraints.constraints.special?.requiredValue).toEqual(true); }); }); }); diff --git a/libs/tools/generator/core/src/metadata/profile-metadata.ts b/libs/tools/generator/core/src/metadata/profile-metadata.ts index 4ac9139f632..7c25f401863 100644 --- a/libs/tools/generator/core/src/metadata/profile-metadata.ts +++ b/libs/tools/generator/core/src/metadata/profile-metadata.ts @@ -1,6 +1,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { SiteId } from "@bitwarden/common/tools/extension"; +import { ExtensionProfileMetadata } from "@bitwarden/common/tools/extension/type"; import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { Constraints } from "@bitwarden/common/tools/types"; @@ -61,13 +61,7 @@ export type CoreProfileMetadata = { * manager. Extension profiles store their data * using the extension system. */ -export type ExtensionProfileMetadata = { - /** distinguishes profile metadata types */ - type: "extension"; - - /** The extension site described by this metadata */ - site: Site; - +export type ForwarderProfileMetadata = ExtensionProfileMetadata & { constraints: ProfileConstraints; }; @@ -77,4 +71,4 @@ export type ExtensionProfileMetadata = { */ export type ProfileMetadata = | CoreProfileMetadata - | ExtensionProfileMetadata; + | ForwarderProfileMetadata; diff --git a/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts b/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts index aba9680a448..d47d5ec9fcb 100644 --- a/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts +++ b/libs/tools/generator/core/src/metadata/username/eff-word-list.spec.ts @@ -20,11 +20,13 @@ describe("username - eff words generator metadata", () => { }); describe("profiles[account]", () => { - let accountProfile: CoreProfileMetadata = null; + let accountProfile: CoreProfileMetadata = null!; beforeEach(() => { const profile = effWordList.profiles[Profile.account]; - if (isCoreProfile(profile)) { + if (isCoreProfile(profile!)) { accountProfile = profile; + } else { + throw new Error("this branch should never run"); } }); diff --git a/libs/tools/generator/core/src/metadata/util.spec.ts b/libs/tools/generator/core/src/metadata/util.spec.ts index 2283699140b..fc88c7a1f57 100644 --- a/libs/tools/generator/core/src/metadata/util.spec.ts +++ b/libs/tools/generator/core/src/metadata/util.spec.ts @@ -1,7 +1,12 @@ +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { GENERATOR_DISK } from "@bitwarden/common/platform/state"; import { VendorId } from "@bitwarden/common/tools/extension"; +import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier"; +import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint"; +import { ObjectKey } from "@bitwarden/common/tools/state/object-key"; import { Algorithm, AlgorithmsByType } from "./data"; -import { ProfileMetadata } from "./profile-metadata"; +import { ProfileContext, ProfileMetadata } from "./profile-metadata"; import { isPasswordAlgorithm, isUsernameAlgorithm, @@ -12,6 +17,19 @@ import { isForwarderProfile, } from "./util"; +const SomeStorage: ObjectKey = { + target: "object", + key: "arbitrary", + state: GENERATOR_DISK, + classifier: new PrivateClassifier(), + format: "classified", + options: { clearOn: [], deserializer: (value) => value }, +}; + +function createConstraints(policies: Policy[], context: ProfileContext) { + return new IdentityConstraint(); +} + describe("credential generator metadata utility functions", () => { describe("isPasswordAlgorithm", () => { it("returns `true` when the algorithm is a password algorithm", () => { @@ -151,10 +169,10 @@ describe("credential generator metadata utility functions", () => { it("returns `true` when the profile's type is `core`", () => { const profile: ProfileMetadata = { type: "core", - storage: null, + storage: SomeStorage, constraints: { default: {}, - create: () => null, + create: createConstraints, }, }; @@ -165,9 +183,10 @@ describe("credential generator metadata utility functions", () => { const profile: ProfileMetadata = { type: "extension", site: "forwarder", + storage: SomeStorage, constraints: { default: {}, - create: () => null, + create: createConstraints, }, }; @@ -179,10 +198,10 @@ describe("credential generator metadata utility functions", () => { it("returns `false` when the profile's type is `core`", () => { const profile: ProfileMetadata = { type: "core", - storage: null, + storage: SomeStorage, constraints: { default: {}, - create: () => null, + create: createConstraints, }, }; @@ -193,9 +212,10 @@ describe("credential generator metadata utility functions", () => { const profile: ProfileMetadata = { type: "extension", site: "forwarder", + storage: SomeStorage, constraints: { default: {}, - create: () => null, + create: createConstraints, }, }; @@ -206,9 +226,10 @@ describe("credential generator metadata utility functions", () => { const profile: ProfileMetadata = { type: "extension", site: "not-a-forwarder" as any, + storage: SomeStorage, constraints: { default: {}, - create: () => null, + create: createConstraints, }, }; diff --git a/libs/tools/generator/core/src/metadata/util.ts b/libs/tools/generator/core/src/metadata/util.ts index e85061720ad..4f38fe4c98d 100644 --- a/libs/tools/generator/core/src/metadata/util.ts +++ b/libs/tools/generator/core/src/metadata/util.ts @@ -1,5 +1,7 @@ +import { VendorId } from "@bitwarden/common/tools/extension"; + import { AlgorithmsByType } from "./data"; -import { CoreProfileMetadata, ExtensionProfileMetadata, ProfileMetadata } from "./profile-metadata"; +import { CoreProfileMetadata, ForwarderProfileMetadata, ProfileMetadata } from "./profile-metadata"; import { CredentialAlgorithm, EmailAlgorithm, @@ -29,6 +31,12 @@ export function isForwarderExtensionId( return algorithm && typeof algorithm === "object" && "forwarder" in algorithm; } +export function toVendorId(algorithm: CredentialAlgorithm): VendorId | undefined { + if (isForwarderExtensionId(algorithm)) { + return algorithm.forwarder as VendorId; + } +} + /** Returns true when the input algorithm is an email algorithm. */ export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm { return AlgorithmsByType.email.includes(algorithm as any) || isForwarderExtensionId(algorithm); @@ -55,6 +63,6 @@ export function isCoreProfile( /** Returns true when the input describes a forwarder extension profile. */ export function isForwarderProfile( value: ProfileMetadata, -): value is ExtensionProfileMetadata { +): value is ForwarderProfileMetadata { return value.type === "extension" && value.site === "forwarder"; } diff --git a/libs/tools/generator/core/src/policies/available-algorithms-policy.ts b/libs/tools/generator/core/src/policies/available-algorithms-policy.ts index f37a8b21a3f..0c44a1a0408 100644 --- a/libs/tools/generator/core/src/policies/available-algorithms-policy.ts +++ b/libs/tools/generator/core/src/policies/available-algorithms-policy.ts @@ -5,13 +5,41 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums"; // implement ADR-0002 import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { CredentialAlgorithm, EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from ".."; +import { + CredentialAlgorithm as LegacyAlgorithm, + EmailAlgorithms, + PasswordAlgorithms, + UsernameAlgorithms, +} from ".."; +import { CredentialAlgorithm } from "../metadata"; /** Reduces policies to a set of available algorithms * @param policies the policies to reduce * @returns the resulting `AlgorithmAvailabilityPolicy` */ -export function availableAlgorithms(policies: Policy[]): CredentialAlgorithm[] { +export function availableAlgorithms(policies: Policy[]): LegacyAlgorithm[] { + const overridePassword = policies + .filter((policy) => policy.type === PolicyType.PasswordGenerator && policy.enabled) + .reduce( + (type, policy) => (type === "password" ? type : (policy.data.overridePasswordType ?? type)), + null as LegacyAlgorithm, + ); + + const policy: LegacyAlgorithm[] = [...EmailAlgorithms, ...UsernameAlgorithms]; + if (overridePassword) { + policy.push(overridePassword); + } else { + policy.push(...PasswordAlgorithms); + } + + return policy; +} + +/** Reduces policies to a set of available algorithms + * @param policies the policies to reduce + * @returns the resulting `AlgorithmAvailabilityPolicy` + */ +export function availableAlgorithms_vNext(policies: Policy[]): CredentialAlgorithm[] { const overridePassword = policies .filter((policy) => policy.type === PolicyType.PasswordGenerator && policy.enabled) .reduce( diff --git a/libs/tools/generator/core/src/services/generator-metadata-provider.ts b/libs/tools/generator/core/src/services/generator-metadata-provider.ts new file mode 100644 index 00000000000..00d443d92ea --- /dev/null +++ b/libs/tools/generator/core/src/services/generator-metadata-provider.ts @@ -0,0 +1,244 @@ +import { + Observable, + distinctUntilChanged, + filter, + first, + map, + shareReplay, + switchMap, + takeUntil, + withLatestFrom, +} from "rxjs"; + +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { BoundDependency } from "@bitwarden/common/tools/dependencies"; +import { ExtensionSite } from "@bitwarden/common/tools/extension"; +import { SemanticLogger } from "@bitwarden/common/tools/log"; +import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; +import { anyComplete } from "@bitwarden/common/tools/rx"; +import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject"; +import { UserStateSubjectDependencyProvider } from "@bitwarden/common/tools/state/user-state-subject-dependency-provider"; + +import { + GeneratorMetadata, + AlgorithmsByType, + CredentialAlgorithm, + CredentialType, + isForwarderExtensionId, + toForwarderMetadata, + Type, +} from "../metadata"; +import { availableAlgorithms_vNext } from "../policies/available-algorithms-policy"; +import { CredentialPreference } from "../types"; + +import { PREFERENCES } from "./credential-preferences"; + +type AlgorithmRequest = { algorithm: CredentialAlgorithm }; +type TypeRequest = { category: CredentialType }; +type MetadataRequest = Partial; + +/** Surfaces contextual information to credential generators */ +export class GeneratorMetadataProvider { + /** Instantiates the context provider + * @param providers dependency injectors for user state subjects + * @param policyService settings constraint lookups + */ + constructor( + private readonly providers: UserStateSubjectDependencyProvider, + private readonly system: SystemServiceProvider, + algorithms: GeneratorMetadata[], + ) { + this.log = providers.log({ type: "GeneratorMetadataProvider" }); + + const site = system.extension.site("forwarder"); + if (!site) { + this.log.panic("forwarder extension site not found"); + } + this.site = site; + + this.generators = new Map(algorithms.map((a) => [a.id, a] as const)); + } + + private readonly site: ExtensionSite; + private readonly log: SemanticLogger; + + private generators: Map>; + + // looks up a set of algorithms; does not enforce policy + algorithms(requested: AlgorithmRequest): CredentialAlgorithm[]; + algorithms(requested: TypeRequest): CredentialAlgorithm[]; + algorithms(requested: MetadataRequest): CredentialAlgorithm[]; + algorithms(requested: MetadataRequest): CredentialAlgorithm[] { + let algorithms: CredentialAlgorithm[]; + if (requested.category) { + let forwarders: CredentialAlgorithm[] = []; + if (requested.category === Type.email) { + forwarders = Array.from(this.site.extensions.keys()).map((forwarder) => ({ forwarder })); + } + + algorithms = AlgorithmsByType[requested.category].concat(forwarders); + } else if (requested.algorithm) { + algorithms = [requested.algorithm]; + } else { + this.log.panic(requested, "algorithm or category required"); + } + + return algorithms; + } + + // emits a function that returns `true` when the input algorithm is available + private isAvailable$( + dependencies: BoundDependency<"account", Account>, + ): Observable<(a: CredentialAlgorithm) => boolean> { + const account$ = dependencies.account$.pipe( + distinctUntilChanged((previous, current) => previous.id === current.id), + shareReplay({ bufferSize: 1, refCount: true }), + ); + + const available$ = account$.pipe( + switchMap((account) => { + const policies$ = this.system.policy.getAll$(PolicyType.PasswordGenerator, account.id).pipe( + map((p) => new Set(availableAlgorithms_vNext(p))), + // complete policy emissions otherwise `switchMap` holds `algorithms$` open indefinitely + takeUntil(anyComplete(account$)), + ); + return policies$; + }), + map((available) => (a: CredentialAlgorithm) => isForwarderExtensionId(a) || available.has(a)), + ); + + return available$; + } + + // looks up a set of algorithms; enforces policy - emits empty list when there's no algorithm available + available$( + requested: AlgorithmRequest, + dependencies: BoundDependency<"account", Account>, + ): Observable[]>; + available$( + requested: TypeRequest, + dependencies: BoundDependency<"account", Account>, + ): Observable[]>; + available$( + requested: MetadataRequest, + dependencies: BoundDependency<"account", Account>, + ): Observable[]> { + let available$: Observable; + if (requested.category) { + const { category } = requested; + + available$ = this.isAvailable$(dependencies).pipe( + map((isAvailable) => AlgorithmsByType[category].filter(isAvailable)), + ); + } else if (requested.algorithm) { + const { algorithm } = requested; + available$ = this.isAvailable$(dependencies).pipe( + map((isAvailable) => (isAvailable(algorithm) ? [algorithm] : [])), + ); + } else { + this.log.panic(requested, "algorithm or category required"); + } + + const result$ = available$.pipe( + map((available) => available.map((algorithm) => this.getMetadata(algorithm))), + ); + + return result$; + } + + // looks up a specific algorithm; enforces policy - observable completes without emission when there's no algorithm available. + algorithm$( + requested: AlgorithmRequest, + dependencies: BoundDependency<"account", Account>, + ): Observable>; + algorithm$( + requested: TypeRequest, + dependencies: BoundDependency<"account", Account>, + ): Observable>; + algorithm$( + requested: MetadataRequest, + dependencies: BoundDependency<"account", Account>, + ): Observable> { + const account$ = dependencies.account$.pipe(shareReplay({ bufferSize: 1, refCount: true })); + + let algorithm$: Observable; + if (requested.category) { + this.log.debug(requested, "retrieving algorithm metadata by category"); + + const { category } = requested; + algorithm$ = this.preferences({ account$ }).pipe( + withLatestFrom(this.isAvailable$({ account$ })), + map(([preferences, isAvailable]) => { + let algorithm: CredentialAlgorithm | undefined = preferences[category].algorithm; + if (isAvailable(algorithm)) { + return algorithm; + } + + const algorithms = AlgorithmsByType[category]; + algorithm = algorithms.find(isAvailable)!; + this.log.debug( + { algorithm, category }, + "preference not available; defaulting the generator algorithm", + ); + + return algorithm; + }), + ); + } else if (requested.algorithm) { + this.log.debug(requested, "retrieving algorithm metadata by algorithm"); + + const { algorithm } = requested; + algorithm$ = this.isAvailable$({ account$ }).pipe( + map((isAvailable) => (isAvailable(algorithm) ? algorithm : undefined)), + first(), + ); + } else { + this.log.panic(requested, "algorithm or category required"); + } + + const result$ = algorithm$.pipe( + filter((value) => !!value), + map((algorithm) => this.getMetadata(algorithm!)), + ); + + return result$; + } + + private getMetadata(algorithm: CredentialAlgorithm) { + let result = null; + if (isForwarderExtensionId(algorithm)) { + const extension = this.site.extensions.get(algorithm.forwarder); + if (!extension) { + this.log.panic(algorithm, "extension not found"); + } + + result = toForwarderMetadata(extension); + } else { + result = this.generators.get(algorithm); + } + + if (!result) { + this.log.panic({ algorithm }, "failed to load metadata"); + } + + return result; + } + + /** Get a subject bound to credential generator preferences. + * @param dependencies.account$ identifies the account to which the preferences are bound + * @returns a subject bound to the user's preferences + * @remarks Preferences determine which algorithms are used when generating a + * credential from a credential category (e.g. `PassX` or `Username`). Preferences + * should not be used to hold navigation history. Use @bitwarden/generator-navigation + * instead. + */ + preferences( + dependencies: BoundDependency<"account", Account>, + ): UserStateSubject { + // FIXME: enforce policy + const subject = new UserStateSubject(PREFERENCES, this.providers, dependencies); + + return subject; + } +} diff --git a/libs/tools/generator/core/src/types/generator-type.ts b/libs/tools/generator/core/src/types/generator-type.ts index 5b74d17fa4a..c75e4329610 100644 --- a/libs/tools/generator/core/src/types/generator-type.ts +++ b/libs/tools/generator/core/src/types/generator-type.ts @@ -1,6 +1,8 @@ +import { VendorId } from "@bitwarden/common/tools/extension"; import { IntegrationId } from "@bitwarden/common/tools/integration"; import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "../data/generator-types"; +import { AlgorithmsByType, CredentialType } from "../metadata"; /** A type of password that may be generated by the credential generator. */ export type PasswordAlgorithm = (typeof PasswordAlgorithms)[number]; @@ -11,7 +13,7 @@ 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 }; +export type ForwarderIntegration = { forwarder: IntegrationId & VendorId }; /** Returns true when the input algorithm is a forwarder integration. */ export function isForwarderIntegration( @@ -74,8 +76,8 @@ export type CredentialCategory = keyof typeof CredentialCategories; /** The kind of credential to generate using a compound configuration. */ // FIXME: extend the preferences to include a preferred forwarder export type CredentialPreference = { - [Key in CredentialCategory]: { - algorithm: (typeof CredentialCategories)[Key][number]; + [Key in CredentialType & CredentialCategory]: { + algorithm: CredentialAlgorithm & (typeof AlgorithmsByType)[Key][number]; updated: Date; }; };