1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-16788] introduce generator metadata (#12757)

This commit is contained in:
✨ Audrey ✨
2025-01-16 10:02:28 -05:00
committed by GitHub
parent 8942f8d440
commit cc311d9a92
21 changed files with 1355 additions and 12 deletions

View File

@@ -0,0 +1,61 @@
import { CredentialAlgorithm, CredentialType } from "./type";
/** Credential generator metadata common across credential generators */
export type AlgorithmMetadata = {
/** Uniquely identifies the credential configuration
* @example
* // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)`
* // to pattern test whether the credential describes a forwarder algorithm
* const meta : AlgorithmMetadata = // ...
* const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {};
*/
id: CredentialAlgorithm;
/** The kind of credential generated by this configuration */
category: CredentialType;
/** Used to order credential algorithms for display purposes.
* Items with lesser weights appear before entries with greater
* weights (i.e. ascending sort).
*/
weight: number;
/** Localization keys */
i18nKeys: {
/** descriptive name of the algorithm */
name: string;
/** explanatory text for the algorithm */
description?: string;
/** labels the generate action */
generateCredential: string;
/** message informing users when the generator produces a new credential */
credentialGenerated: string;
/* labels the action that assigns a generated value to a domain object */
useCredential: string;
/** labels the generated output */
credentialType: string;
/** labels the copy output action */
copyCredential: string;
};
/** fine-tunings for generator user experiences */
capabilities: {
/** `true` when the generator supports autogeneration
* @remarks this property is useful when credential generation
* carries side effects, such as configuring a service external
* to Bitwarden.
*/
autogenerate: boolean;
/** Well-known fields to display on the options panel or collect from the environment.
* @remarks: at present, this is only used by forwarders
*/
fields: string[];
};
};

View File

@@ -0,0 +1,48 @@
import { deepFreeze } from "@bitwarden/common/tools/util";
/** algorithms for generating credentials */
export const Algorithm = Object.freeze({
/** A password composed of random characters */
password: "password",
/** A password composed of random words from the EFF word list */
passphrase: "passphrase",
/** A username composed of words from the EFF word list */
username: "username",
/** An email username composed of random characters */
catchall: "catchall",
/** An email username composed of words from the EFF word list */
plusAddress: "subaddress",
} as const);
/** categorizes credentials according to their use-case outside of Bitwarden */
export const Type = Object.freeze({
password: "password",
username: "username",
email: "email",
} as const);
/** categorizes settings according to their expected use-case within Bitwarden */
export const Profile = Object.freeze({
/** account-level generator options. This is the default.
* @remarks these are the options displayed on the generator tab
*/
account: "account",
// FIXME: consider adding a profile for bitwarden's master password
});
/** Credential generation algorithms grouped by purpose. */
export const AlgorithmsByType = deepFreeze({
/** Algorithms that produce passwords */
[Type.password]: [Algorithm.password, Algorithm.passphrase] as const,
/** Algorithms that produce usernames */
[Type.username]: [Algorithm.username] as const,
/** Algorithms that produce email addresses */
[Type.email]: [Algorithm.catchall, Algorithm.plusAddress] as const,
} as const);

View File

@@ -0,0 +1,65 @@
import { mock } from "jest-mock-extended";
import { EmailRandomizer } from "../../engine";
import { CatchallConstraints } from "../../policies/catchall-constraints";
import { CatchallGenerationOptions, GeneratorDependencyProvider } from "../../types";
import { Profile } from "../data";
import { CoreProfileMetadata } from "../profile-metadata";
import { isCoreProfile } from "../util";
import catchall from "./catchall";
const dependencyProvider = mock<GeneratorDependencyProvider>();
describe("email - catchall generator metadata", () => {
describe("engine.create", () => {
it("returns an email randomizer", () => {
expect(catchall.engine.create(dependencyProvider)).toBeInstanceOf(EmailRandomizer);
});
});
describe("profiles[account]", () => {
let accountProfile: CoreProfileMetadata<CatchallGenerationOptions> = null;
beforeEach(() => {
const profile = catchall.profiles[Profile.account];
if (isCoreProfile(profile)) {
accountProfile = profile;
}
});
describe("storage.options.deserializer", () => {
it("returns its input", () => {
const value: CatchallGenerationOptions = {
catchallType: "random",
catchallDomain: "example.com",
};
const result = accountProfile.storage.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("constraints.create", () => {
// these tests check that the wiring is correct by exercising the behavior
// of functionality encapsulated by `create`. These methods may fail if the
// enclosed behaviors change.
it("creates a catchall constraints", () => {
const context = { defaultConstraints: {} };
const constraints = accountProfile.constraints.create([], context);
expect(constraints).toBeInstanceOf(CatchallConstraints);
});
it("extracts the domain from context.email", () => {
const context = { email: "foo@example.com", defaultConstraints: {} };
const constraints = accountProfile.constraints.create([], context) as CatchallConstraints;
expect(constraints.domain).toEqual("example.com");
});
});
});
});

View File

@@ -0,0 +1,70 @@
import { GENERATOR_DISK } from "@bitwarden/common/platform/state";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
import { deepFreeze } from "@bitwarden/common/tools/util";
import { EmailRandomizer } from "../../engine";
import { CatchallConstraints } from "../../policies/catchall-constraints";
import {
CatchallGenerationOptions,
CredentialGenerator,
GeneratorDependencyProvider,
} from "../../types";
import { Algorithm, Type, Profile } from "../data";
import { GeneratorMetadata } from "../generator-metadata";
const catchall: GeneratorMetadata<CatchallGenerationOptions> = deepFreeze({
id: Algorithm.catchall,
category: Type.email,
weight: 210,
i18nKeys: {
name: "catchallEmail",
description: "catchallEmailDesc",
credentialType: "email",
generateCredential: "generateEmail",
credentialGenerated: "emailGenerated",
copyCredential: "copyEmail",
useCredential: "useThisEmail",
},
capabilities: {
autogenerate: true,
fields: [],
},
engine: {
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<CatchallGenerationOptions> {
return new EmailRandomizer(dependencies.randomizer);
},
},
profiles: {
[Profile.account]: {
type: "core",
storage: {
key: "catchallGeneratorSettings",
target: "object",
format: "plain",
classifier: new PublicClassifier<CatchallGenerationOptions>([
"catchallType",
"catchallDomain",
]),
state: GENERATOR_DISK,
initial: {
catchallType: "random",
catchallDomain: "",
},
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
},
constraints: {
default: { catchallDomain: { minLength: 1 } },
create(_policies, context) {
return new CatchallConstraints(context.email ?? "");
},
},
},
},
});
export default catchall;

View File

@@ -0,0 +1,4 @@
// Forwarders are pending integration with the extension API
//
// They use the 300-block of weights and derive their metadata
// using logic similar to `toCredentialGeneratorConfiguration`

View File

@@ -0,0 +1,65 @@
import { mock } from "jest-mock-extended";
import { EmailRandomizer } from "../../engine";
import { SubaddressConstraints } from "../../policies/subaddress-constraints";
import { SubaddressGenerationOptions, GeneratorDependencyProvider } from "../../types";
import { Profile } from "../data";
import { CoreProfileMetadata } from "../profile-metadata";
import { isCoreProfile } from "../util";
import plusAddress from "./plus-address";
const dependencyProvider = mock<GeneratorDependencyProvider>();
describe("email - plus address generator metadata", () => {
describe("engine.create", () => {
it("returns an email randomizer", () => {
expect(plusAddress.engine.create(dependencyProvider)).toBeInstanceOf(EmailRandomizer);
});
});
describe("profiles[account]", () => {
let accountProfile: CoreProfileMetadata<SubaddressGenerationOptions> = null;
beforeEach(() => {
const profile = plusAddress.profiles[Profile.account];
if (isCoreProfile(profile)) {
accountProfile = profile;
}
});
describe("storage.options.deserializer", () => {
it("returns its input", () => {
const value: SubaddressGenerationOptions = {
subaddressType: "random",
subaddressEmail: "foo@example.com",
};
const result = accountProfile.storage.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("constraints.create", () => {
// these tests check that the wiring is correct by exercising the behavior
// of functionality encapsulated by `create`. These methods may fail if the
// enclosed behaviors change.
it("creates a subaddress constraints", () => {
const context = { defaultConstraints: {} };
const constraints = accountProfile.constraints.create([], context);
expect(constraints).toBeInstanceOf(SubaddressConstraints);
});
it("sets the constraint email to context.email", () => {
const context = { email: "bar@example.com", defaultConstraints: {} };
const constraints = accountProfile.constraints.create([], context) as SubaddressConstraints;
expect(constraints.email).toEqual("bar@example.com");
});
});
});
});

View File

@@ -0,0 +1,72 @@
import { GENERATOR_DISK } from "@bitwarden/common/platform/state";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
import { deepFreeze } from "@bitwarden/common/tools/util";
import { EmailRandomizer } from "../../engine";
import { SubaddressConstraints } from "../../policies/subaddress-constraints";
import {
CredentialGenerator,
GeneratorDependencyProvider,
SubaddressGenerationOptions,
} from "../../types";
import { Algorithm, Profile, Type } from "../data";
import { GeneratorMetadata } from "../generator-metadata";
const plusAddress: GeneratorMetadata<SubaddressGenerationOptions> = deepFreeze({
id: Algorithm.plusAddress,
category: Type.email,
weight: 200,
i18nKeys: {
name: "plusAddressedEmail",
description: "plusAddressedEmailDesc",
credentialType: "email",
generateCredential: "generateEmail",
credentialGenerated: "emailGenerated",
copyCredential: "copyEmail",
useCredential: "useThisEmail",
},
capabilities: {
autogenerate: true,
fields: [],
},
engine: {
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<SubaddressGenerationOptions> {
return new EmailRandomizer(dependencies.randomizer);
},
},
profiles: {
[Profile.account]: {
type: "core",
storage: {
key: "subaddressGeneratorSettings",
target: "object",
format: "plain",
classifier: new PublicClassifier<SubaddressGenerationOptions>([
"subaddressType",
"subaddressEmail",
]),
state: GENERATOR_DISK,
initial: {
subaddressType: "random",
subaddressEmail: "",
},
options: {
deserializer(value) {
return value;
},
clearOn: ["logout"],
},
},
constraints: {
default: {},
create(_policy, context) {
return new SubaddressConstraints(context.email ?? "");
},
},
},
},
});
export default plusAddress;

View File

@@ -0,0 +1,29 @@
import { CredentialGenerator, GeneratorDependencyProvider } from "../types";
import { AlgorithmMetadata } from "./algorithm-metadata";
import { Profile } from "./data";
import { ProfileMetadata } from "./profile-metadata";
/** Extends the algorithm metadata with storage and engine configurations.
* @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 GeneratorMetadata<Options> = AlgorithmMetadata & {
/** An algorithm that generates credentials when ran. */
engine: {
/** Factory for the generator
*/
create: (randomizer: GeneratorDependencyProvider) => CredentialGenerator<Options>;
};
/** Defines parameters for credential generation */
profiles: {
/** profiles supported by this generator; when `undefined`,
* the generator does not support the profile.
*/
[K in keyof typeof Profile]?: ProfileMetadata<Options>;
};
};

View File

@@ -0,0 +1,102 @@
import { mock } from "jest-mock-extended";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { PasswordRandomizer } from "../../engine";
import { PassphrasePolicyConstraints } from "../../policies";
import { PassphraseGenerationOptions, GeneratorDependencyProvider } from "../../types";
import { Profile } from "../data";
import { CoreProfileMetadata } from "../profile-metadata";
import { isCoreProfile } from "../util";
import effPassphrase from "./eff-word-list";
const dependencyProvider = mock<GeneratorDependencyProvider>();
describe("password - eff words generator metadata", () => {
describe("engine.create", () => {
it("returns an email randomizer", () => {
expect(effPassphrase.engine.create(dependencyProvider)).toBeInstanceOf(PasswordRandomizer);
});
});
describe("profiles[account]", () => {
let accountProfile: CoreProfileMetadata<PassphraseGenerationOptions> = null;
beforeEach(() => {
const profile = effPassphrase.profiles[Profile.account];
if (isCoreProfile(profile)) {
accountProfile = profile;
}
});
describe("storage.options.deserializer", () => {
it("returns its input", () => {
const value: PassphraseGenerationOptions = { ...accountProfile.storage.initial };
const result = accountProfile.storage.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("constraints.create", () => {
// these tests check that the wiring is correct by exercising the behavior
// of functionality encapsulated by `create`. These methods may fail if the
// enclosed behaviors change.
it("creates a passphrase policy constraints", () => {
const context = { defaultConstraints: accountProfile.constraints.default };
const constraints = accountProfile.constraints.create([], context);
expect(constraints).toBeInstanceOf(PassphrasePolicyConstraints);
});
it("forwards the policy to the constraints", () => {
const context = { defaultConstraints: accountProfile.constraints.default };
const policies = [
{
type: PolicyType.PasswordGenerator,
data: {
minNumberWords: 6,
capitalize: false,
includeNumber: false,
},
},
] as Policy[];
const constraints = accountProfile.constraints.create(policies, context);
expect(constraints.constraints.numWords.min).toEqual(6);
});
it("combines multiple policies in the constraints", () => {
const context = { defaultConstraints: accountProfile.constraints.default };
const policies = [
{
type: PolicyType.PasswordGenerator,
data: {
minNumberWords: 6,
capitalize: false,
includeNumber: false,
},
},
{
type: PolicyType.PasswordGenerator,
data: {
minNumberWords: 3,
capitalize: true,
includeNumber: false,
},
},
] as Policy[];
const constraints = accountProfile.constraints.create(policies, context);
expect(constraints.constraints.numWords.min).toEqual(6);
expect(constraints.constraints.capitalize.requiredValue).toEqual(true);
});
});
});
});

View File

@@ -0,0 +1,91 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { GENERATOR_DISK } from "@bitwarden/common/platform/state";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { PasswordRandomizer } from "../../engine";
import { passphraseLeastPrivilege, PassphrasePolicyConstraints } from "../../policies";
import {
CredentialGenerator,
GeneratorDependencyProvider,
PassphraseGenerationOptions,
} from "../../types";
import { Algorithm, Profile, Type } from "../data";
import { GeneratorMetadata } from "../generator-metadata";
const passphrase: GeneratorMetadata<PassphraseGenerationOptions> = {
id: Algorithm.passphrase,
category: Type.password,
weight: 110,
i18nKeys: {
name: "passphrase",
credentialType: "passphrase",
generateCredential: "generatePassphrase",
credentialGenerated: "passphraseGenerated",
copyCredential: "copyPassphrase",
useCredential: "useThisPassphrase",
},
capabilities: {
autogenerate: false,
fields: [],
},
engine: {
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<PassphraseGenerationOptions> {
return new PasswordRandomizer(dependencies.randomizer);
},
},
profiles: {
[Profile.account]: {
type: "core",
storage: {
key: "passphraseGeneratorSettings",
target: "object",
format: "plain",
classifier: new PublicClassifier<PassphraseGenerationOptions>([
"numWords",
"wordSeparator",
"capitalize",
"includeNumber",
]),
state: GENERATOR_DISK,
initial: {
numWords: 6,
wordSeparator: "-",
capitalize: false,
includeNumber: false,
},
options: {
deserializer(value) {
return value;
},
clearOn: ["logout"],
},
} satisfies ObjectKey<PassphraseGenerationOptions>,
constraints: {
type: PolicyType.PasswordGenerator,
default: {
wordSeparator: { maxLength: 1 },
numWords: {
min: 3,
max: 20,
recommendation: 6,
},
},
create(policies, context) {
const initial = {
minNumberWords: 0,
capitalize: false,
includeNumber: false,
};
const policy = policies.reduce(passphraseLeastPrivilege, initial);
const constraints = new PassphrasePolicyConstraints(policy, context.defaultConstraints);
return constraints;
},
},
},
},
};
export default passphrase;

View File

@@ -0,0 +1,105 @@
import { mock } from "jest-mock-extended";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { PasswordRandomizer } from "../../engine";
import { DynamicPasswordPolicyConstraints } from "../../policies";
import { PasswordGenerationOptions, GeneratorDependencyProvider } from "../../types";
import { Profile } from "../data";
import { CoreProfileMetadata } from "../profile-metadata";
import { isCoreProfile } from "../util";
import password from "./random-password";
const dependencyProvider = mock<GeneratorDependencyProvider>();
describe("password - characters generator metadata", () => {
describe("engine.create", () => {
it("returns an email randomizer", () => {
expect(password.engine.create(dependencyProvider)).toBeInstanceOf(PasswordRandomizer);
});
});
describe("profiles[account]", () => {
let accountProfile: CoreProfileMetadata<PasswordGenerationOptions> = null;
beforeEach(() => {
const profile = password.profiles[Profile.account];
if (isCoreProfile(profile)) {
accountProfile = profile;
}
});
describe("storage.options.deserializer", () => {
it("returns its input", () => {
const value: PasswordGenerationOptions = { ...accountProfile.storage.initial };
const result = accountProfile.storage.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("constraints.create", () => {
// these tests check that the wiring is correct by exercising the behavior
// of functionality encapsulated by `create`. These methods may fail if the
// enclosed behaviors change.
it("creates a passphrase policy constraints", () => {
const context = { defaultConstraints: accountProfile.constraints.default };
const constraints = accountProfile.constraints.create([], context);
expect(constraints).toBeInstanceOf(DynamicPasswordPolicyConstraints);
});
it("forwards the policy to the constraints", () => {
const context = { defaultConstraints: accountProfile.constraints.default };
const policies = [
{
type: PolicyType.PasswordGenerator,
enabled: true,
data: {
minLength: 10,
capitalize: false,
useNumbers: false,
},
},
] as Policy[];
const constraints = accountProfile.constraints.create(policies, context);
expect(constraints.constraints.length.min).toEqual(10);
});
it("combines multiple policies in the constraints", () => {
const context = { defaultConstraints: accountProfile.constraints.default };
const policies = [
{
type: PolicyType.PasswordGenerator,
enabled: true,
data: {
minLength: 14,
useSpecial: false,
useNumbers: false,
},
},
{
type: PolicyType.PasswordGenerator,
enabled: true,
data: {
minLength: 10,
useSpecial: true,
includeNumber: false,
},
},
] as Policy[];
const constraints = accountProfile.constraints.create(policies, context);
expect(constraints.constraints.length.min).toEqual(14);
expect(constraints.constraints.special.requiredValue).toEqual(true);
});
});
});
});

View File

@@ -0,0 +1,117 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { GENERATOR_DISK } from "@bitwarden/common/platform/state";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
import { deepFreeze } from "@bitwarden/common/tools/util";
import { PasswordRandomizer } from "../../engine";
import { DynamicPasswordPolicyConstraints, passwordLeastPrivilege } from "../../policies";
import {
CredentialGenerator,
GeneratorDependencyProvider,
PasswordGeneratorSettings,
} from "../../types";
import { Algorithm, Profile, Type } from "../data";
import { GeneratorMetadata } from "../generator-metadata";
const password: GeneratorMetadata<PasswordGeneratorSettings> = deepFreeze({
id: Algorithm.password,
category: Type.password,
weight: 100,
i18nKeys: {
name: "password",
generateCredential: "generatePassword",
credentialGenerated: "passwordGenerated",
credentialType: "password",
copyCredential: "copyPassword",
useCredential: "useThisPassword",
},
capabilities: {
autogenerate: true,
fields: [],
},
engine: {
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<PasswordGeneratorSettings> {
return new PasswordRandomizer(dependencies.randomizer);
},
},
profiles: {
[Profile.account]: {
type: "core",
storage: {
key: "passwordGeneratorSettings",
target: "object",
format: "plain",
classifier: new PublicClassifier<PasswordGeneratorSettings>([
"length",
"ambiguous",
"uppercase",
"minUppercase",
"lowercase",
"minLowercase",
"number",
"minNumber",
"special",
"minSpecial",
]),
state: GENERATOR_DISK,
initial: {
length: 14,
ambiguous: true,
uppercase: true,
minUppercase: 1,
lowercase: true,
minLowercase: 1,
number: true,
minNumber: 1,
special: false,
minSpecial: 0,
},
options: {
deserializer(value) {
return value;
},
clearOn: ["logout"],
},
},
constraints: {
type: PolicyType.PasswordGenerator,
default: {
length: {
min: 5,
max: 128,
recommendation: 14,
},
minNumber: {
min: 0,
max: 9,
},
minSpecial: {
min: 0,
max: 9,
},
},
create(policies, context) {
const initial = {
minLength: 0,
useUppercase: false,
useLowercase: false,
useNumbers: false,
numberCount: 0,
useSpecial: false,
specialCount: 0,
};
const policy = policies.reduce(passwordLeastPrivilege, initial);
const constraints = new DynamicPasswordPolicyConstraints(
policy,
context.defaultConstraints,
);
return constraints;
},
},
},
},
});
export default password;

View File

@@ -0,0 +1,80 @@
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
import { SiteId } from "@bitwarden/common/tools/extension";
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
import { Constraints } from "@bitwarden/common/tools/types";
import { GeneratorConstraints } from "../types";
export type ProfileContext<Options> = {
/** The email address for the current user;
* `undefined` when no email is available.
*/
email?: string;
/** Default application limits for the profile */
defaultConstraints: Constraints<Options>;
};
type ProfileConstraints<Options> = {
/** The key used to locate this profile's policies in the admin console.
* When this type is undefined, no policy is defined for the profile.
*/
type?: PolicyType;
/** default application limits for this profile; these are overridden
* by the policy
*/
default: Constraints<Options>;
/** Constructs generator constraints from a policy.
* @param policies the administrative policy to apply to the provided constraints
* When `type` is undefined then `policy` is `undefined` this is an empty array.
* @param defaultConstraints application constraints; typically those defined in
* the `default` member, above.
* @returns the generator constraints to apply to this profile's options.
*/
create: (policies: Policy[], context: ProfileContext<Options>) => GeneratorConstraints<Options>;
};
/** Generator profiles partition generator operations
* according to where they're used within the password
* manager. Core profiles store their data using the
* generator's system storage.
*/
export type CoreProfileMetadata<Options> = {
/** distinguishes profile metadata types */
type: "core";
/** plaintext import buffer */
import?: ObjectKey<Options, Record<string, never>, Options> & { format: "plain" };
/** persistent storage location */
storage: ObjectKey<Options>;
/** policy enforced when saving the options */
constraints: ProfileConstraints<Options>;
};
/** Generator profiles partition generator operations
* according to where they're used within the password
* manager. Extension profiles store their data
* using the extension system.
*/
export type ExtensionProfileMetadata<Options, Site extends SiteId> = {
/** distinguishes profile metadata types */
type: "extension";
/** The extension site described by this metadata */
site: Site;
constraints: ProfileConstraints<Options>;
};
/** Generator profiles partition generator operations
* according to where they're used within the password
* manager
*/
export type ProfileMetadata<Options> =
| CoreProfileMetadata<Options>
| ExtensionProfileMetadata<Options, "forwarder">;

View File

@@ -0,0 +1,28 @@
import { VendorId } from "@bitwarden/common/tools/extension";
import { AlgorithmsByType, Profile, Type } from "./data";
/** categorizes credentials according to their use-case outside of Bitwarden */
export type CredentialType = keyof typeof Type;
/** categorizes credentials according to their expected use-case within Bitwarden */
export type GeneratorProfile = keyof typeof Profile;
/** A type of password that may be generated by the credential generator. */
export type PasswordAlgorithm = (typeof AlgorithmsByType.password)[number];
/** A type of username that may be generated by the credential generator. */
export type UsernameAlgorithm = (typeof AlgorithmsByType.username)[number];
/** A type of email address that may be generated by the credential generator. */
export type EmailAlgorithm = (typeof AlgorithmsByType.email)[number] | ForwarderExtensionId;
/** Identifies a forwarding service */
export type ForwarderExtensionId = { forwarder: VendorId };
/** A type of credential that can be generated by the credential generator. */
// this is defined in terms of `AlgorithmsByType` to typecheck the keys of
// `AlgorithmsByType` against the keys of `CredentialType`.
export type CredentialAlgorithm =
| (typeof AlgorithmsByType)[CredentialType][number]
| ForwarderExtensionId;

View File

@@ -0,0 +1,58 @@
import { mock } from "jest-mock-extended";
import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint";
import { UsernameRandomizer } from "../../engine";
import { EffUsernameGenerationOptions, GeneratorDependencyProvider } from "../../types";
import { Profile } from "../data";
import { CoreProfileMetadata } from "../profile-metadata";
import { isCoreProfile } from "../util";
import effWordList from "./eff-word-list";
const dependencyProvider = mock<GeneratorDependencyProvider>();
describe("username - eff words generator metadata", () => {
describe("engine.create", () => {
it("returns an email randomizer", () => {
expect(effWordList.engine.create(dependencyProvider)).toBeInstanceOf(UsernameRandomizer);
});
});
describe("profiles[account]", () => {
let accountProfile: CoreProfileMetadata<EffUsernameGenerationOptions> = null;
beforeEach(() => {
const profile = effWordList.profiles[Profile.account];
if (isCoreProfile(profile)) {
accountProfile = profile;
}
});
describe("storage.options.deserializer", () => {
it("returns its input", () => {
const value: EffUsernameGenerationOptions = {
wordCapitalize: true,
wordIncludeNumber: true,
};
const result = accountProfile.storage.options.deserializer(value);
expect(result).toBe(value);
});
});
describe("constraints.create", () => {
// these tests check that the wiring is correct by exercising the behavior
// of functionality encapsulated by `create`. These methods may fail if the
// enclosed behaviors change.
it("creates a effWordList constraints", () => {
const context = { defaultConstraints: {} };
const constraints = accountProfile.constraints.create([], context);
expect(constraints).toBeInstanceOf(IdentityConstraint);
});
});
});
});

View File

@@ -0,0 +1,70 @@
import { GENERATOR_DISK } from "@bitwarden/common/platform/state";
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint";
import { deepFreeze } from "@bitwarden/common/tools/util";
import { UsernameRandomizer } from "../../engine";
import {
CredentialGenerator,
EffUsernameGenerationOptions,
GeneratorDependencyProvider,
} from "../../types";
import { Algorithm, Profile, Type } from "../data";
import { GeneratorMetadata } from "../generator-metadata";
const effWordList: GeneratorMetadata<EffUsernameGenerationOptions> = deepFreeze({
id: Algorithm.username,
category: Type.username,
weight: 400,
i18nKeys: {
name: "randomWord",
credentialType: "username",
generateCredential: "generateUsername",
credentialGenerated: "usernameGenerated",
copyCredential: "copyUsername",
useCredential: "useThisUsername",
},
capabilities: {
autogenerate: true,
fields: [],
},
engine: {
create(
dependencies: GeneratorDependencyProvider,
): CredentialGenerator<EffUsernameGenerationOptions> {
return new UsernameRandomizer(dependencies.randomizer);
},
},
profiles: {
[Profile.account]: {
type: "core",
storage: {
key: "effUsernameGeneratorSettings",
target: "object",
format: "plain",
classifier: new PublicClassifier<EffUsernameGenerationOptions>([
"wordCapitalize",
"wordIncludeNumber",
]),
state: GENERATOR_DISK,
initial: {
wordCapitalize: false,
wordIncludeNumber: false,
website: null,
},
options: {
deserializer: (value) => value,
clearOn: ["logout"],
},
},
constraints: {
default: {},
create(_policies, _context) {
return new IdentityConstraint<EffUsernameGenerationOptions>();
},
},
},
},
});
export default effWordList;

View File

@@ -0,0 +1,218 @@
import { VendorId } from "@bitwarden/common/tools/extension";
import { Algorithm, AlgorithmsByType } from "./data";
import { ProfileMetadata } from "./profile-metadata";
import {
isPasswordAlgorithm,
isUsernameAlgorithm,
isForwarderExtensionId,
isEmailAlgorithm,
isSameAlgorithm,
isCoreProfile,
isForwarderProfile,
} from "./util";
describe("credential generator metadata utility functions", () => {
describe("isPasswordAlgorithm", () => {
it("returns `true` when the algorithm is a password algorithm", () => {
for (const algorithm of AlgorithmsByType.password) {
expect(isPasswordAlgorithm(algorithm)).toBe(true);
}
});
it("returns `false` when the algorithm is an email algorithm", () => {
for (const algorithm of AlgorithmsByType.email) {
expect(isPasswordAlgorithm(algorithm)).toBe(false);
}
});
it("returns `false` when the algorithm is a username algorithm", () => {
for (const algorithm of AlgorithmsByType.username) {
expect(isPasswordAlgorithm(algorithm)).toBe(false);
}
});
it("returns `false` when the algorithm is a forwarder extension", () => {
expect(isPasswordAlgorithm({ forwarder: "bitwarden" as VendorId })).toBe(false);
});
});
describe("isUsernameAlgorithm", () => {
it("returns `false` when the algorithm is a password algorithm", () => {
for (const algorithm of AlgorithmsByType.password) {
expect(isUsernameAlgorithm(algorithm)).toBe(false);
}
});
it("returns `false` when the algorithm is an email algorithm", () => {
for (const algorithm of AlgorithmsByType.email) {
expect(isUsernameAlgorithm(algorithm)).toBe(false);
}
});
it("returns `true` when the algorithm is a username algorithm", () => {
for (const algorithm of AlgorithmsByType.username) {
expect(isUsernameAlgorithm(algorithm)).toBe(true);
}
});
it("returns `false` when the algorithm is a forwarder extension", () => {
expect(isUsernameAlgorithm({ forwarder: "bitwarden" as VendorId })).toBe(false);
});
});
describe("isForwarderExtensionId", () => {
it("returns `false` when the algorithm is a password algorithm", () => {
for (const algorithm of AlgorithmsByType.password) {
expect(isForwarderExtensionId(algorithm)).toBe(false);
}
});
it("returns `false` when the algorithm is an email algorithm", () => {
for (const algorithm of AlgorithmsByType.email) {
expect(isForwarderExtensionId(algorithm)).toBe(false);
}
});
it("returns `false` when the algorithm is a username algorithm", () => {
for (const algorithm of AlgorithmsByType.username) {
expect(isForwarderExtensionId(algorithm)).toBe(false);
}
});
it("returns `true` when the algorithm is a forwarder extension", () => {
expect(isForwarderExtensionId({ forwarder: "bitwarden" as VendorId })).toBe(true);
});
});
describe("isEmailAlgorithm", () => {
it("returns `false` when the algorithm is a password algorithm", () => {
for (const algorithm of AlgorithmsByType.password) {
expect(isEmailAlgorithm(algorithm)).toBe(false);
}
});
it("returns `true` when the algorithm is an email algorithm", () => {
for (const algorithm of AlgorithmsByType.email) {
expect(isEmailAlgorithm(algorithm)).toBe(true);
}
});
it("returns `false` when the algorithm is a username algorithm", () => {
for (const algorithm of AlgorithmsByType.username) {
expect(isEmailAlgorithm(algorithm)).toBe(false);
}
});
it("returns `true` when the algorithm is a forwarder extension", () => {
expect(isEmailAlgorithm({ forwarder: "bitwarden" as VendorId })).toBe(true);
});
});
describe("isSameAlgorithm", () => {
it("returns `true` when the algorithms are equal", () => {
// identical object
expect(isSameAlgorithm(Algorithm.catchall, Algorithm.catchall)).toBe(true);
// equal object
expect(isSameAlgorithm(Algorithm.catchall, `${Algorithm.catchall}`)).toBe(true);
});
it("returns `false` when the algorithms are different", () => {
// not an exhaustive list
expect(isSameAlgorithm(Algorithm.catchall, Algorithm.passphrase)).toBe(false);
expect(isSameAlgorithm(Algorithm.passphrase, Algorithm.password)).toBe(false);
expect(isSameAlgorithm(Algorithm.password, Algorithm.plusAddress)).toBe(false);
expect(isSameAlgorithm(Algorithm.plusAddress, Algorithm.username)).toBe(false);
expect(isSameAlgorithm(Algorithm.username, Algorithm.passphrase)).toBe(false);
});
it("returns `true` when the algorithms refer to a forwarder with a matching vendor", () => {
const someVendor = { forwarder: "bitwarden" as VendorId };
const sameVendor = { forwarder: "bitwarden" as VendorId };
expect(isSameAlgorithm(someVendor, sameVendor)).toBe(true);
});
it("returns `false` when the algorithms refer to a forwarder with a different vendor", () => {
const someVendor = { forwarder: "bitwarden" as VendorId };
const sameVendor = { forwarder: "bytewarden" as VendorId };
expect(isSameAlgorithm(someVendor, sameVendor)).toBe(false);
});
it("returns `false` when the algorithms refer to a forwarder and a core algorithm", () => {
const someVendor = { forwarder: "bitwarden" as VendorId };
// not an exhaustive list
expect(isSameAlgorithm(someVendor, Algorithm.plusAddress)).toBe(false);
expect(isSameAlgorithm(Algorithm.username, someVendor)).toBe(false);
});
});
describe("isCoreProfile", () => {
it("returns `true` when the profile's type is `core`", () => {
const profile: ProfileMetadata<object> = {
type: "core",
storage: null,
constraints: {
default: {},
create: () => null,
},
};
expect(isCoreProfile(profile)).toBe(true);
});
it("returns `false` when the profile's type is `extension`", () => {
const profile: ProfileMetadata<object> = {
type: "extension",
site: "forwarder",
constraints: {
default: {},
create: () => null,
},
};
expect(isCoreProfile(profile)).toBe(false);
});
});
describe("isForwarderProfile", () => {
it("returns `false` when the profile's type is `core`", () => {
const profile: ProfileMetadata<object> = {
type: "core",
storage: null,
constraints: {
default: {},
create: () => null,
},
};
expect(isForwarderProfile(profile)).toBe(false);
});
it("returns `true` when the profile's type is `extension` and the site is `forwarder`", () => {
const profile: ProfileMetadata<object> = {
type: "extension",
site: "forwarder",
constraints: {
default: {},
create: () => null,
},
};
expect(isForwarderProfile(profile)).toBe(true);
});
it("returns `false` when the profile's type is `extension` and the site is not `forwarder`", () => {
const profile: ProfileMetadata<object> = {
type: "extension",
site: "not-a-forwarder" as any,
constraints: {
default: {},
create: () => null,
},
};
expect(isForwarderProfile(profile)).toBe(false);
});
});
});

View File

@@ -0,0 +1,60 @@
import { AlgorithmsByType } from "./data";
import { CoreProfileMetadata, ExtensionProfileMetadata, ProfileMetadata } from "./profile-metadata";
import {
CredentialAlgorithm,
EmailAlgorithm,
ForwarderExtensionId,
PasswordAlgorithm,
UsernameAlgorithm,
} from "./type";
/** Returns true when the input algorithm is a password algorithm. */
export function isPasswordAlgorithm(
algorithm: CredentialAlgorithm,
): algorithm is PasswordAlgorithm {
return AlgorithmsByType.password.includes(algorithm as any);
}
/** Returns true when the input algorithm is a username algorithm. */
export function isUsernameAlgorithm(
algorithm: CredentialAlgorithm,
): algorithm is UsernameAlgorithm {
return AlgorithmsByType.username.includes(algorithm as any);
}
/** Returns true when the input algorithm is a forwarder integration. */
export function isForwarderExtensionId(
algorithm: CredentialAlgorithm,
): algorithm is ForwarderExtensionId {
return algorithm && typeof algorithm === "object" && "forwarder" in algorithm;
}
/** Returns true when the input algorithm is an email algorithm. */
export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm {
return AlgorithmsByType.email.includes(algorithm as any) || isForwarderExtensionId(algorithm);
}
/** Returns true when the algorithms are the same. */
export function isSameAlgorithm(lhs: CredentialAlgorithm, rhs: CredentialAlgorithm) {
if (lhs === rhs) {
return true;
} else if (isForwarderExtensionId(lhs) && isForwarderExtensionId(rhs)) {
return lhs.forwarder === rhs.forwarder;
} else {
return false;
}
}
/** Returns true when the input describes a core profile. */
export function isCoreProfile<Options>(
value: ProfileMetadata<Options>,
): value is CoreProfileMetadata<Options> {
return value.type === "core";
}
/** Returns true when the input describes a forwarder extension profile. */
export function isForwarderProfile<Options>(
value: ProfileMetadata<Options>,
): value is ExtensionProfileMetadata<Options, "forwarder"> {
return value.type === "extension" && value.site === "forwarder";
}

View File

@@ -24,7 +24,7 @@ export class CatchallConstraints implements StateConstraints<CatchallGenerationO
this.domain = parsed.groups.domain;
}
}
private domain: string;
readonly domain: string;
constraints: Readonly<Constraints<CatchallGenerationOptions>> = {};

View File

@@ -2,58 +2,58 @@
*/
export type PasswordGeneratorSettings = {
/** The length of the password selected by the user */
length: number;
length?: number;
/** `true` when ambiguous characters may be included in the output.
* `false` when ambiguous characters should not be included in the output.
*/
ambiguous: boolean;
ambiguous?: boolean;
/** `true` when uppercase ASCII characters should be included in the output
* This value defaults to `false.
*/
uppercase: boolean;
uppercase?: boolean;
/** The minimum number of uppercase characters to include in the output.
* The value is ignored when `uppercase` is `false`.
* The value defaults to 1 when `uppercase` is `true`.
*/
minUppercase: number;
minUppercase?: number;
/** `true` when lowercase ASCII characters should be included in the output.
* This value defaults to `false`.
*/
lowercase: boolean;
lowercase?: boolean;
/** The minimum number of lowercase characters to include in the output.
* The value defaults to 1 when `lowercase` is `true`.
* The value defaults to 0 when `lowercase` is `false`.
*/
minLowercase: number;
minLowercase?: number;
/** Whether or not to include ASCII digits in the output
* This value defaults to `true` when `minNumber` is at least 1.
* This value defaults to `false` when `minNumber` is less than 1.
*/
number: boolean;
number?: boolean;
/** The minimum number of digits to include in the output.
* The value defaults to 1 when `number` is `true`.
* The value defaults to 0 when `number` is `false`.
*/
minNumber: number;
minNumber?: number;
/** Whether or not to include special characters in the output.
* This value defaults to `true` when `minSpecial` is at least 1.
* This value defaults to `false` when `minSpecial` is less than 1.
*/
special: boolean;
special?: boolean;
/** The minimum number of special characters to include in the output.
* This value defaults to 1 when `special` is `true`.
* This value defaults to 0 when `special` is `false`.
*/
minSpecial: number;
minSpecial?: number;
};
/** Request format for password credential generation.

View File

@@ -107,7 +107,7 @@ export function optionsToRandomAsciiRequest(options: PasswordGenerationOptions)
DefaultPasswordGenerationOptions.special,
DefaultPasswordGenerationOptions.minSpecial,
),
ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous,
ambiguous: options.ambiguous ?? DefaultPasswordGenerationOptions.ambiguous!,
all: 0,
};