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/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/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/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/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; }; };