1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-20 10:13:31 +00:00

[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
This commit is contained in:
✨ Audrey ✨
2024-10-23 12:11:42 -04:00
committed by GitHub
parent e67577cc39
commit eff9a423da
45 changed files with 3403 additions and 1005 deletions

View File

@@ -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([

View File

@@ -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<PassphraseGenerationOptions> {
return new PasswordRandomizer(randomizer);
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<PassphraseGenerationOptions> {
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<PasswordGenerationOptions> {
return new PasswordRandomizer(randomizer);
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<PasswordGenerationOptions> {
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<EffUsernameGenerationOptions> {
return new UsernameRandomizer(randomizer);
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<EffUsernameGenerationOptions> {
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<CatchallGenerationOptions> {
return new EmailRandomizer(randomizer);
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<CatchallGenerationOptions> {
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<SubaddressGenerationOptions> {
return new EmailRandomizer(randomizer);
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<SubaddressGenerationOptions> {
return new EmailRandomizer(dependencies.randomizer);
},
},
settings: {
@@ -215,6 +247,48 @@ const SUBADDRESS = Object.freeze({
},
} satisfies CredentialGeneratorConfiguration<SubaddressGenerationOptions, NoPolicy>);
export function toCredentialGeneratorConfiguration<Settings extends ApiSettings = ApiSettings>(
configuration: ForwarderConfiguration<Settings>,
) {
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<Settings>();
},
toConstraints(_policy: NoPolicy) {
return new IdentityConstraint<Settings>();
},
},
} satisfies CredentialGeneratorConfiguration<Settings, NoPolicy>);
return forwarder;
}
/** Generator configurations */
export const Generators = Object.freeze({
/** Passphrase generator configuration */

View File

@@ -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<ApiSettings> {
const maybeForwarder = integrations.get(id);
if (maybeForwarder && "forwarder" in maybeForwarder) {
return maybeForwarder as ForwarderConfiguration<ApiSettings>;
} else {
return null;
}
}

View File

@@ -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<Request, ForwarderContext<Settings>, 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<Settings>;
/** forwarder settings storage */
settingsConstraints: Constraints<Settings>;
/** Well-known fields to display on the forwarder screen */
request: readonly ForwarderRequestFields[];
/** forwarder settings storage
* @deprecated use local.settings instead
*/
settings: UserKeyDefinition<Settings>;
/** 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<Settings>;
/** locally stored data; forwarder-partitioned */
local: {
/** integration settings storage */
settings: ObjectKey<Settings>;
/** plaintext import buffer - used during data migrations */
import?: ObjectKey<Settings, Record<string, never>, Settings>;
};
/** createForwardingEmail RPC definition */
createForwardingEmail: CreateForwardingEmailRpcDef<Settings, Request>;

View File

@@ -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<ApiSettings> {
/** 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<ApiSettings>,
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<Settings>(
configuration: ForwarderConfiguration<Settings>,
settings: Settings,
) {
return new ForwarderContext(configuration, settings, this.i18nService);
}
private createForwardingAddress<Settings extends ApiSettings>(
configuration: ForwarderConfiguration<Settings>,
settings: Settings,
) {
const context = this.createContext(configuration, settings);
const rpc = new CreateForwardingAddressRpc<Settings>(configuration, context);
return rpc;
}
private getAccountId<Settings extends ApiSettings>(
configuration: ForwarderConfiguration<Settings>,
settings: Settings,
) {
if (!configuration.forwarder.getAccountId) {
return null;
}
const context = this.createContext(configuration, settings);
const rpc = new GetAccountIdRpc<Settings>(configuration, context);
return rpc;
}
}

View File

@@ -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<AddyIoSettings>(),
state: GENERATOR_DISK,
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<AddyIoSettings>,
import: {
key: "forwarder.AddyIo.local.import",
target: "object",
format: "plain",
classifier: new PublicClassifier<AddyIoSettings>(["token", "baseUrl", "domain"]),
state: GENERATOR_MEMORY,
options: {
deserializer: (value) => value,
clearOn: ["logout", "lock"],
},
} satisfies ObjectKey<AddyIoSettings, Record<string, never>, AddyIoSettings>,
},
settings: new UserKeyDefinition<AddyIoSettings>(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({

View File

@@ -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<DuckDuckGoSettings>(),
state: GENERATOR_DISK,
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<DuckDuckGoSettings>,
import: {
key: "forwarder.DuckDuckGo.local.import",
target: "object",
format: "plain",
classifier: new PublicClassifier<DuckDuckGoSettings>(["token"]),
state: GENERATOR_MEMORY,
options: {
deserializer: (value) => value,
clearOn: ["logout", "lock"],
},
} satisfies ObjectKey<DuckDuckGoSettings, Record<string, never>, DuckDuckGoSettings>,
},
settings: new UserKeyDefinition<DuckDuckGoSettings>(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

View File

@@ -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<FastmailSettings>(),
state: GENERATOR_DISK,
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<FastmailSettings>,
import: {
key: "forwarder.Fastmail.local.import",
target: "object",
format: "plain",
classifier: new PublicClassifier<FastmailSettings>(["token"]),
state: GENERATOR_MEMORY,
options: {
deserializer: (value) => value,
clearOn: ["logout", "lock"],
},
} satisfies ObjectKey<FastmailSettings, Record<string, never>, FastmailSettings>,
},
settings: new UserKeyDefinition<FastmailSettings>(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

View File

@@ -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<FirefoxRelaySettings>(),
state: GENERATOR_DISK,
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<FirefoxRelaySettings>,
import: {
key: "forwarder.Firefox.local.import",
target: "object",
format: "plain",
classifier: new PublicClassifier<FirefoxRelaySettings>(["token"]),
state: GENERATOR_MEMORY,
options: {
deserializer: (value) => value,
clearOn: ["logout", "lock"],
},
} satisfies ObjectKey<FirefoxRelaySettings, Record<string, never>, FirefoxRelaySettings>,
},
settings: new UserKeyDefinition<FirefoxRelaySettings>(GENERATOR_DISK, "firefoxRelayForwarder", {
deserializer: (value) => value,
clearOn: [],
@@ -52,7 +91,6 @@ const forwarder = Object.freeze({
clearOn: ["logout"],
},
),
createForwardingEmail,
} as const);
// integration-wide configuration

View File

@@ -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<ForwardEmailSettings>(),
state: GENERATOR_DISK,
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<ForwardEmailSettings>,
import: {
key: "forwarder.ForwardEmail.local.import",
target: "object",
format: "plain",
classifier: new PublicClassifier<ForwardEmailSettings>(["token", "domain"]),
state: GENERATOR_MEMORY,
options: {
deserializer: (value) => value,
clearOn: ["logout", "lock"],
},
} satisfies ObjectKey<ForwardEmailSettings, Record<string, never>, ForwardEmailSettings>,
},
settings: new UserKeyDefinition<ForwardEmailSettings>(GENERATOR_DISK, "forwardEmailForwarder", {
deserializer: (value) => value,
clearOn: [],

View File

@@ -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<SimpleLoginSettings>(),
state: GENERATOR_DISK,
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
} satisfies ObjectKey<SimpleLoginSettings>,
import: {
key: "forwarder.SimpleLogin.local.import",
target: "object",
format: "plain",
classifier: new PublicClassifier<SimpleLoginSettings>(["token", "baseUrl"]),
state: GENERATOR_MEMORY,
options: {
deserializer: (value) => value,
clearOn: ["logout", "lock"],
},
} satisfies ObjectKey<SimpleLoginSettings, Record<string, never>, SimpleLoginSettings>,
},
settings: new UserKeyDefinition<SimpleLoginSettings>(GENERATOR_DISK, "simpleLoginForwarder", {
deserializer: (value) => value,
clearOn: [],
@@ -57,7 +96,6 @@ const forwarder = Object.freeze({
clearOn: ["logout"],
},
),
createForwardingEmail,
} as const);
// integration-wide configuration

View File

@@ -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<void>();
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<void>();
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<void>();
const completing$ = new Subject<void>();
let completed = false;
anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) });
completing$.complete();
expect(completed).toBe(true);
});
it("ignores emissions", () => {
const input$ = new Subject<number>();
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<void>();
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<void>();
let connected = false;
const source$ = new Subject<number>().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<void>();
const source$ = new Subject<number>();
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<void>();
const watchB$ = new Subject<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
let connected = false;
const source$ = new Subject<number>().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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
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<void>();
const source$ = new Subject<number>();
const expected = { some: "error" };
let error = null;
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
watch$.error(expected);
expect(error).toEqual(expected);
});
});

View File

@@ -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<Target>() {
return pipe(map((_) => new DefaultPolicyEvaluator<Target>()));
};
}
/** 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<any> | Observable<any>[]): Observable<any> {
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<T>(watch$: Observable<any> | Observable<any>[]) {
const watching$ = Array.isArray(watch$) ? watch$ : [watch$];
return pipe(
connect<T, Observable<T>>((source$) => {
// this subscription is safe because `source$` connects only after there
// is an external subscriber.
const source = new ReplaySubject<T>(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<T>(watch$: Observable<any>) {
return pipe(
connect<T, Observable<T>>((source$) => {
const source = new ReplaySubject<T>(1);
source$.subscribe(source);
return watch$
.pipe(
ready(source),
concatMap(() => source.pipe(first())),
)
.pipe(takeUntil(anyComplete(source)));
}),
);
}

View File

@@ -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<SomeSettings, SomePolicy> = {
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<Randomizer>();
const i18nService = mock<I18nService>();
const apiService = mock<ApiService>();
const encryptService = mock<EncryptService>();
const cryptoService = mock<CryptoService>();
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<Response>()));
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<void>();
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<void>();
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<void>();
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();

View File

@@ -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<OnDependency> & Partial<UserDepend
* When `website$` errors, the generator forwards the error.
*/
website$?: Observable<string>;
integration$?: Observable<IntegrationId>;
};
type Algorithms$Dependencies = Partial<UserDependency>;
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<string>(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<any> = 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<CredentialGeneratorInfo[]>;
): Observable<AlgorithmInfo[]>;
algorithms$(
category: CredentialCategory[],
dependencies?: Algorithms$Dependencies,
): Observable<CredentialGeneratorInfo[]>;
): Observable<AlgorithmInfo[]>;
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;
}

View File

@@ -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<Settings, Policy> = CredentialGeneratorInfo & {
/** An algorithm that generates credentials when ran. */
engine: {
@@ -40,7 +109,7 @@ export type CredentialGeneratorConfiguration<Settings, Policy> = 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<Settings>;
create: (randomizer: GeneratorDependencyProvider) => CredentialGenerator<Settings>;
};
/** Defines the stored parameters for credential generation */
settings: {
@@ -51,7 +120,10 @@ export type CredentialGeneratorConfiguration<Settings, Policy> = CredentialGener
constraints: Constraints<Settings>;
/** storage location for account-global settings */
account: UserKeyDefinition<Settings>;
account: UserKeyDefinition<Settings> | ObjectKey<Settings>;
/** storage location for *plaintext* settings imports */
import?: UserKeyDefinition<Settings> | ObjectKey<Settings, Record<string, never>, Settings>;
};
/** defines how to construct policy for this settings instance */

View File

@@ -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<UsernameAlgorithm[]>,
/** Lists algorithms in the "email" credential category */
email: EmailAlgorithms as Readonly<EmailAlgorithm[]>,
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. */

View File

@@ -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.