mirror of
https://github.com/bitwarden/browser.git
synced 2025-01-09 19:28:06 +01:00
[PM-7289] implement generator libraries (#9549)
This is a copy of the files. The source in `@bitwarden/common` will be deleted once all of the applications have been ported to the library.
This commit is contained in:
parent
fe82dbe2b9
commit
882a432ca6
@ -4,9 +4,9 @@ import { PolicyService } from "../../../admin-console/abstractions/policy/policy
|
||||
import { PolicyType } from "../../../admin-console/enums";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { distinctIfShallowMatch, reduceCollection } from "../../rx";
|
||||
import { GeneratorNavigationService } from "../abstractions/generator-navigation.service.abstraction";
|
||||
import { GENERATOR_SETTINGS } from "../key-definitions";
|
||||
import { distinctIfShallowMatch, reduceCollection } from "../rx-operators";
|
||||
|
||||
import { DefaultGeneratorNavigation, GeneratorNavigation } from "./generator-navigation";
|
||||
import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator";
|
||||
|
@ -1,45 +1,10 @@
|
||||
import { distinctUntilChanged, map, OperatorFunction, pipe } from "rxjs";
|
||||
import { map, pipe } from "rxjs";
|
||||
|
||||
import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx";
|
||||
|
||||
import { DefaultPolicyEvaluator } from "./default-policy-evaluator";
|
||||
import { PolicyConfiguration } from "./policies";
|
||||
|
||||
/**
|
||||
* An observable operator that reduces an emitted collection to a single object,
|
||||
* returning a default if all items are ignored.
|
||||
* @param reduce The reduce function to apply to the filtered collection. The
|
||||
* first argument is the accumulator, and the second is the current item. The
|
||||
* return value is the new accumulator.
|
||||
* @param defaultValue The default value to return if the collection is empty. The
|
||||
* default value is also the initial value of the accumulator.
|
||||
*/
|
||||
export function reduceCollection<Item, Accumulator>(
|
||||
reduce: (acc: Accumulator, value: Item) => Accumulator,
|
||||
defaultValue: Accumulator,
|
||||
): OperatorFunction<Item[], Accumulator> {
|
||||
return map((values: Item[]) => {
|
||||
const reduced = (values ?? []).reduce(reduce, structuredClone(defaultValue));
|
||||
return reduced;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An observable operator that emits distinct values by checking that all
|
||||
* values in the previous entry match the next entry. This method emits
|
||||
* when a key is added and does not when a key is removed.
|
||||
* @remarks This method checks objects. It does not check items in arrays.
|
||||
*/
|
||||
export function distinctIfShallowMatch<Item>(): OperatorFunction<Item, Item> {
|
||||
return distinctUntilChanged((previous, current) => {
|
||||
let isDistinct = true;
|
||||
|
||||
for (const key in current) {
|
||||
isDistinct &&= previous[key] === current[key];
|
||||
}
|
||||
|
||||
return isDistinct;
|
||||
});
|
||||
}
|
||||
|
||||
/** Maps an administrative console policy to a policy evaluator using the provided configuration.
|
||||
* @param configuration the configuration that constructs the evaluator.
|
||||
*/
|
||||
|
@ -2,12 +2,11 @@
|
||||
* include structuredClone in test environment.
|
||||
* @jest-environment ../../../../shared/test.environment.ts
|
||||
*/
|
||||
|
||||
import { of, firstValueFrom } from "rxjs";
|
||||
|
||||
import { awaitAsync, trackEmissions } from "../../../spec";
|
||||
import { awaitAsync, trackEmissions } from "../../spec";
|
||||
|
||||
import { distinctIfShallowMatch, reduceCollection } from "./rx-operators";
|
||||
import { distinctIfShallowMatch, reduceCollection } from "./rx";
|
||||
|
||||
describe("reduceCollection", () => {
|
||||
it.each([[null], [undefined], [[]]])(
|
38
libs/common/src/tools/rx.ts
Normal file
38
libs/common/src/tools/rx.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { map, distinctUntilChanged, OperatorFunction } from "rxjs";
|
||||
|
||||
/**
|
||||
* An observable operator that reduces an emitted collection to a single object,
|
||||
* returning a default if all items are ignored.
|
||||
* @param reduce The reduce function to apply to the filtered collection. The
|
||||
* first argument is the accumulator, and the second is the current item. The
|
||||
* return value is the new accumulator.
|
||||
* @param defaultValue The default value to return if the collection is empty. The
|
||||
* default value is also the initial value of the accumulator.
|
||||
*/
|
||||
export function reduceCollection<Item, Accumulator>(
|
||||
reduce: (acc: Accumulator, value: Item) => Accumulator,
|
||||
defaultValue: Accumulator,
|
||||
): OperatorFunction<Item[], Accumulator> {
|
||||
return map((values: Item[]) => {
|
||||
const reduced = (values ?? []).reduce(reduce, structuredClone(defaultValue));
|
||||
return reduced;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An observable operator that emits distinct values by checking that all
|
||||
* values in the previous entry match the next entry. This method emits
|
||||
* when a key is added and does not when a key is removed.
|
||||
* @remarks This method checks objects. It does not check items in arrays.
|
||||
*/
|
||||
export function distinctIfShallowMatch<Item>(): OperatorFunction<Item, Item> {
|
||||
return distinctUntilChanged((previous, current) => {
|
||||
let isDistinct = true;
|
||||
|
||||
for (const key in current) {
|
||||
isDistinct &&= previous[key] === current[key];
|
||||
}
|
||||
|
||||
return isDistinct;
|
||||
});
|
||||
}
|
@ -8,6 +8,6 @@ module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "../../../shared/test.environment.ts",
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||
prefix: "<rootDir>/../../../",
|
||||
prefix: "<rootDir>/../../",
|
||||
}),
|
||||
};
|
||||
|
@ -8,6 +8,6 @@ module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "../../../shared/test.environment.ts",
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||
prefix: "<rootDir>/../../../",
|
||||
prefix: "<rootDir>/../../",
|
||||
}),
|
||||
};
|
||||
|
@ -0,0 +1,42 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { SingleUserState } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { PolicyEvaluator } from "./policy-evaluator.abstraction";
|
||||
|
||||
/** Tailors the generator service to generate a specific kind of credentials */
|
||||
export abstract class GeneratorStrategy<Options, Policy> {
|
||||
/** Retrieve application state that persists across locks.
|
||||
* @param userId: identifies the user state to retrieve
|
||||
* @returns the strategy's durable user state
|
||||
*/
|
||||
durableState: (userId: UserId) => SingleUserState<Options>;
|
||||
|
||||
/** Gets the default options. */
|
||||
defaults$: (userId: UserId) => Observable<Options>;
|
||||
|
||||
/** Identifies the policy enforced by the generator. */
|
||||
policy: PolicyType;
|
||||
|
||||
/** Operator function that converts a policy collection observable to a single
|
||||
* policy evaluator observable.
|
||||
* @param policy The policy being evaluated.
|
||||
* @returns the policy evaluator. If `policy` is is `null` or `undefined`,
|
||||
* then the evaluator defaults to the application's limits.
|
||||
* @throws when the policy's type does not match the generator's policy type.
|
||||
*/
|
||||
toEvaluator: () => (
|
||||
source: Observable<AdminPolicy[]>,
|
||||
) => Observable<PolicyEvaluator<Policy, Options>>;
|
||||
|
||||
/** Generates credentials from the given options.
|
||||
* @param options The options used to generate the credentials.
|
||||
* @returns a promise that resolves to the generated credentials.
|
||||
*/
|
||||
generate: (options: Options) => Promise<string>;
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { PolicyEvaluator } from "./policy-evaluator.abstraction";
|
||||
|
||||
/** Generates credentials used for user authentication
|
||||
* @typeParam Options the credential generation configuration
|
||||
* @typeParam Policy the policy enforced by the generator
|
||||
*/
|
||||
export abstract class GeneratorService<Options, Policy> {
|
||||
/** An observable monitoring the options saved to disk.
|
||||
* The observable updates when the options are saved.
|
||||
* @param userId: Identifies the user making the request
|
||||
*/
|
||||
options$: (userId: UserId) => Observable<Options>;
|
||||
|
||||
/** An observable monitoring the options used to enforce policy.
|
||||
* The observable updates when the policy changes.
|
||||
* @param userId: Identifies the user making the request
|
||||
*/
|
||||
evaluator$: (userId: UserId) => Observable<PolicyEvaluator<Policy, Options>>;
|
||||
|
||||
/** Gets the default options. */
|
||||
defaults$: (userId: UserId) => Observable<Options>;
|
||||
|
||||
/** Enforces the policy on the given options
|
||||
* @param userId: Identifies the user making the request
|
||||
* @param options the options to enforce the policy on
|
||||
* @returns a new instance of the options with the policy enforced
|
||||
*/
|
||||
enforcePolicy: (userId: UserId, options: Options) => Promise<Options>;
|
||||
|
||||
/** Generates credentials
|
||||
* @param options the options to generate credentials with
|
||||
* @returns a promise that resolves with the generated credentials
|
||||
*/
|
||||
generate: (options: Options) => Promise<string>;
|
||||
|
||||
/** Saves the given options to disk.
|
||||
* @param userId: Identifies the user making the request
|
||||
* @param options the options to save
|
||||
* @returns a promise that resolves when the options are saved
|
||||
*/
|
||||
saveOptions: (userId: UserId, options: Options) => Promise<void>;
|
||||
}
|
6
libs/tools/generator/core/src/abstractions/index.ts
Normal file
6
libs/tools/generator/core/src/abstractions/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { GeneratorHistoryService } from "../../../extensions/src/history/generator-history.abstraction";
|
||||
export { GeneratorNavigationService } from "../../../extensions/src/navigation/generator-navigation.service.abstraction";
|
||||
export { GeneratorService } from "./generator.service.abstraction";
|
||||
export { GeneratorStrategy } from "./generator-strategy.abstraction";
|
||||
export { PolicyEvaluator } from "./policy-evaluator.abstraction";
|
||||
export { Randomizer } from "./randomizer";
|
@ -0,0 +1,28 @@
|
||||
/** Applies policy to a generation request */
|
||||
export abstract class PolicyEvaluator<Policy, PolicyTarget> {
|
||||
/** The policy to enforce */
|
||||
policy: Policy;
|
||||
|
||||
/** Returns true when a policy is being enforced by the evaluator.
|
||||
* @remarks `applyPolicy` should be called when a policy is not in
|
||||
* effect to enforce the application's default policy.
|
||||
*/
|
||||
policyInEffect: boolean;
|
||||
|
||||
/** Apply policy to a set of options.
|
||||
* @param options The options to build from. These options are not altered.
|
||||
* @returns A complete generation request with policy applied.
|
||||
* @remarks This method only applies policy overrides.
|
||||
* Pass the result to `sanitize` to ensure consistency.
|
||||
*/
|
||||
applyPolicy: (options: PolicyTarget) => PolicyTarget;
|
||||
|
||||
/** Ensures internal options consistency.
|
||||
* @param options The options to cascade. These options are not altered.
|
||||
* @returns A new generation request with cascade applied.
|
||||
* @remarks This method fills null and undefined values by looking at
|
||||
* pairs of flags and values (e.g. `number` and `minNumber`). If the flag
|
||||
* and value are inconsistent, the flag cascades to the value.
|
||||
*/
|
||||
sanitize: (options: PolicyTarget) => PolicyTarget;
|
||||
}
|
39
libs/tools/generator/core/src/abstractions/randomizer.ts
Normal file
39
libs/tools/generator/core/src/abstractions/randomizer.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { WordOptions } from "../types";
|
||||
|
||||
/** Entropy source for credential generation. */
|
||||
export interface Randomizer {
|
||||
/** picks a random entry from a list.
|
||||
* @param list random entry source. This must have at least one entry.
|
||||
* @returns a promise that resolves with a random entry from the list.
|
||||
*/
|
||||
pick<Entry>(list: Array<Entry>): Promise<Entry>;
|
||||
|
||||
/** picks a random word from a list.
|
||||
* @param list random entry source. This must have at least one entry.
|
||||
* @param options customizes the output word
|
||||
* @returns a promise that resolves with a random word from the list.
|
||||
*/
|
||||
pickWord(list: Array<string>, options?: WordOptions): Promise<string>;
|
||||
|
||||
/** Shuffles a list of items
|
||||
* @param list random entry source. This must have at least two entries.
|
||||
* @param options.copy shuffles a copy of the input when this is true.
|
||||
* Defaults to true.
|
||||
* @returns a promise that resolves with the randomized list.
|
||||
*/
|
||||
shuffle<Entry>(items: Array<Entry>): Promise<Array<Entry>>;
|
||||
|
||||
/** Generates a string containing random lowercase ASCII characters and numbers.
|
||||
* @param length the number of characters to generate
|
||||
* @returns a promise that resolves with the randomized string.
|
||||
*/
|
||||
chars(length: number): Promise<string>;
|
||||
|
||||
/** Selects an integer value from a range by randomly choosing it from
|
||||
* a uniform distribution.
|
||||
* @param min the minimum value in the range, inclusive.
|
||||
* @param max the minimum value in the range, inclusive.
|
||||
* @returns a promise that resolves with the randomized string.
|
||||
*/
|
||||
uniform(min: number, max: number): Promise<number>;
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { EmailDomainOptions, SelfHostedApiOptions } from "../types";
|
||||
|
||||
export const DefaultAddyIoOptions: SelfHostedApiOptions & EmailDomainOptions = Object.freeze({
|
||||
website: null,
|
||||
baseUrl: "https://app.addy.io",
|
||||
token: "",
|
||||
domain: "",
|
||||
});
|
@ -0,0 +1,8 @@
|
||||
import { CatchallGenerationOptions } from "../types";
|
||||
|
||||
/** The default options for catchall address generation. */
|
||||
export const DefaultCatchallOptions: CatchallGenerationOptions = Object.freeze({
|
||||
catchallType: "random",
|
||||
catchallDomain: "",
|
||||
website: null,
|
||||
});
|
@ -0,0 +1,6 @@
|
||||
import { ApiOptions } from "../types";
|
||||
|
||||
export const DefaultDuckDuckGoOptions: ApiOptions = Object.freeze({
|
||||
website: null,
|
||||
token: "",
|
||||
});
|
@ -0,0 +1,8 @@
|
||||
import { EffUsernameGenerationOptions } from "../types";
|
||||
|
||||
/** The default options for EFF long word generation. */
|
||||
export const DefaultEffUsernameOptions: EffUsernameGenerationOptions = Object.freeze({
|
||||
wordCapitalize: false,
|
||||
wordIncludeNumber: false,
|
||||
website: null,
|
||||
});
|
@ -0,0 +1,8 @@
|
||||
import { ApiOptions, EmailPrefixOptions } from "../types";
|
||||
|
||||
export const DefaultFastmailOptions: ApiOptions & EmailPrefixOptions = Object.freeze({
|
||||
website: "",
|
||||
domain: "",
|
||||
prefix: "",
|
||||
token: "",
|
||||
});
|
@ -0,0 +1,6 @@
|
||||
import { ApiOptions } from "../types";
|
||||
|
||||
export const DefaultFirefoxRelayOptions: ApiOptions = Object.freeze({
|
||||
website: null,
|
||||
token: "",
|
||||
});
|
@ -0,0 +1,7 @@
|
||||
import { ApiOptions, EmailDomainOptions } from "../types";
|
||||
|
||||
export const DefaultForwardEmailOptions: ApiOptions & EmailDomainOptions = Object.freeze({
|
||||
website: null,
|
||||
token: "",
|
||||
domain: "",
|
||||
});
|
@ -0,0 +1,15 @@
|
||||
function initializeBoundaries() {
|
||||
const numWords = Object.freeze({
|
||||
min: 3,
|
||||
max: 20,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
numWords,
|
||||
});
|
||||
}
|
||||
|
||||
/** Immutable default boundaries for passphrase generation.
|
||||
* These are used when the policy does not override a value.
|
||||
*/
|
||||
export const DefaultPassphraseBoundaries = initializeBoundaries();
|
@ -0,0 +1,10 @@
|
||||
import { PassphraseGenerationOptions } from "../types";
|
||||
|
||||
/** The default options for passphrase generation. */
|
||||
export const DefaultPassphraseGenerationOptions: Partial<PassphraseGenerationOptions> =
|
||||
Object.freeze({
|
||||
numWords: 3,
|
||||
wordSeparator: "-",
|
||||
capitalize: false,
|
||||
includeNumber: false,
|
||||
});
|
@ -0,0 +1,27 @@
|
||||
function initializeBoundaries() {
|
||||
const length = Object.freeze({
|
||||
min: 5,
|
||||
max: 128,
|
||||
});
|
||||
|
||||
const minDigits = Object.freeze({
|
||||
min: 0,
|
||||
max: 9,
|
||||
});
|
||||
|
||||
const minSpecialCharacters = Object.freeze({
|
||||
min: 0,
|
||||
max: 9,
|
||||
});
|
||||
|
||||
return Object.freeze({
|
||||
length,
|
||||
minDigits,
|
||||
minSpecialCharacters,
|
||||
});
|
||||
}
|
||||
|
||||
/** Immutable default boundaries for password generation.
|
||||
* These are used when the policy does not override a value.
|
||||
*/
|
||||
export const DefaultPasswordBoundaries = initializeBoundaries();
|
@ -0,0 +1,16 @@
|
||||
import { PasswordGenerationOptions } from "../types";
|
||||
|
||||
import { DefaultPasswordBoundaries } from "./default-password-boundaries";
|
||||
|
||||
/** The default options for password generation. */
|
||||
export const DefaultPasswordGenerationOptions: Partial<PasswordGenerationOptions> = Object.freeze({
|
||||
length: 14,
|
||||
minLength: DefaultPasswordBoundaries.length.min,
|
||||
ambiguous: true,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
number: true,
|
||||
minNumber: 1,
|
||||
special: false,
|
||||
minSpecial: 0,
|
||||
});
|
@ -0,0 +1,7 @@
|
||||
import { SelfHostedApiOptions } from "../types";
|
||||
|
||||
export const DefaultSimpleLoginOptions: SelfHostedApiOptions = Object.freeze({
|
||||
website: null,
|
||||
baseUrl: "https://app.simplelogin.io",
|
||||
token: "",
|
||||
});
|
@ -0,0 +1,8 @@
|
||||
import { SubaddressGenerationOptions } from "../types";
|
||||
|
||||
/** The default options for email subaddress generation. */
|
||||
export const DefaultSubaddressOptions: SubaddressGenerationOptions = Object.freeze({
|
||||
subaddressType: "random",
|
||||
subaddressEmail: "",
|
||||
website: null,
|
||||
});
|
@ -0,0 +1,8 @@
|
||||
import { PassphraseGeneratorPolicy } from "../types";
|
||||
|
||||
/** The default options for password generation policy. */
|
||||
export const DisabledPassphraseGeneratorPolicy: PassphraseGeneratorPolicy = Object.freeze({
|
||||
minNumberWords: 0,
|
||||
capitalize: false,
|
||||
includeNumber: false,
|
||||
});
|
@ -0,0 +1,12 @@
|
||||
import { PasswordGeneratorPolicy } from "../types";
|
||||
|
||||
/** The default options for password generation policy. */
|
||||
export const DisabledPasswordGeneratorPolicy: PasswordGeneratorPolicy = Object.freeze({
|
||||
minLength: 0,
|
||||
useUppercase: false,
|
||||
useLowercase: false,
|
||||
useNumbers: false,
|
||||
numberCount: 0,
|
||||
useSpecial: false,
|
||||
specialCount: 0,
|
||||
});
|
49
libs/tools/generator/core/src/data/forwarders.ts
Normal file
49
libs/tools/generator/core/src/data/forwarders.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { ForwarderMetadata } from "../types";
|
||||
|
||||
/** Metadata about an email forwarding service.
|
||||
* @remarks This is used to populate the forwarder selection list
|
||||
* and to identify forwarding services in error messages.
|
||||
*/
|
||||
export const Forwarders = Object.freeze({
|
||||
/** For https://addy.io/ */
|
||||
AddyIo: Object.freeze({
|
||||
id: "anonaddy",
|
||||
name: "Addy.io",
|
||||
validForSelfHosted: true,
|
||||
} as ForwarderMetadata),
|
||||
|
||||
/** For https://duckduckgo.com/email/ */
|
||||
DuckDuckGo: Object.freeze({
|
||||
id: "duckduckgo",
|
||||
name: "DuckDuckGo",
|
||||
validForSelfHosted: false,
|
||||
} as ForwarderMetadata),
|
||||
|
||||
/** For https://www.fastmail.com. */
|
||||
Fastmail: Object.freeze({
|
||||
id: "fastmail",
|
||||
name: "Fastmail",
|
||||
validForSelfHosted: true,
|
||||
} as ForwarderMetadata),
|
||||
|
||||
/** For https://relay.firefox.com/ */
|
||||
FirefoxRelay: Object.freeze({
|
||||
id: "firefoxrelay",
|
||||
name: "Firefox Relay",
|
||||
validForSelfHosted: false,
|
||||
} as ForwarderMetadata),
|
||||
|
||||
/** For https://forwardemail.net/ */
|
||||
ForwardEmail: Object.freeze({
|
||||
id: "forwardemail",
|
||||
name: "Forward Email",
|
||||
validForSelfHosted: true,
|
||||
} as ForwarderMetadata),
|
||||
|
||||
/** For https://simplelogin.io/ */
|
||||
SimpleLogin: Object.freeze({
|
||||
id: "simplelogin",
|
||||
name: "SimpleLogin",
|
||||
validForSelfHosted: true,
|
||||
} as ForwarderMetadata),
|
||||
});
|
17
libs/tools/generator/core/src/data/index.ts
Normal file
17
libs/tools/generator/core/src/data/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export * from "./default-addy-io-options";
|
||||
export * from "./default-catchall-options";
|
||||
export * from "./default-duck-duck-go-options";
|
||||
export * from "./default-fastmail-options";
|
||||
export * from "./default-forward-email-options";
|
||||
export * from "./default-passphrase-boundaries";
|
||||
export * from "./default-password-boundaries";
|
||||
export * from "./default-eff-username-options";
|
||||
export * from "./default-firefox-relay-options";
|
||||
export * from "./default-passphrase-generation-options";
|
||||
export * from "./default-password-generation-options";
|
||||
export * from "./default-subaddress-generator-options";
|
||||
export * from "./default-simple-login-options";
|
||||
export * from "./disabled-passphrase-generator-policy";
|
||||
export * from "./disabled-password-generator-policy";
|
||||
export * from "./forwarders";
|
||||
export * from "./policies";
|
29
libs/tools/generator/core/src/data/policies.ts
Normal file
29
libs/tools/generator/core/src/data/policies.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { DisabledPassphraseGeneratorPolicy, DisabledPasswordGeneratorPolicy } from "../data";
|
||||
import {
|
||||
passphraseLeastPrivilege,
|
||||
passwordLeastPrivilege,
|
||||
PassphraseGeneratorOptionsEvaluator,
|
||||
PasswordGeneratorOptionsEvaluator,
|
||||
} from "../policies";
|
||||
import { PassphraseGeneratorPolicy, PasswordGeneratorPolicy, PolicyConfiguration } from "../types";
|
||||
|
||||
const PASSPHRASE = Object.freeze({
|
||||
disabledValue: DisabledPassphraseGeneratorPolicy,
|
||||
combine: passphraseLeastPrivilege,
|
||||
createEvaluator: (policy) => new PassphraseGeneratorOptionsEvaluator(policy),
|
||||
} as PolicyConfiguration<PassphraseGeneratorPolicy, PassphraseGeneratorOptionsEvaluator>);
|
||||
|
||||
const PASSWORD = Object.freeze({
|
||||
disabledValue: DisabledPasswordGeneratorPolicy,
|
||||
combine: passwordLeastPrivilege,
|
||||
createEvaluator: (policy) => new PasswordGeneratorOptionsEvaluator(policy),
|
||||
} as PolicyConfiguration<PasswordGeneratorPolicy, PasswordGeneratorOptionsEvaluator>);
|
||||
|
||||
/** Policy configurations */
|
||||
export const Policies = Object.freeze({
|
||||
/** Passphrase policy configuration */
|
||||
Passphrase: PASSPHRASE,
|
||||
|
||||
/** Passphrase policy configuration */
|
||||
Password: PASSWORD,
|
||||
});
|
@ -0,0 +1,62 @@
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
|
||||
import { Randomizer } from "../abstractions";
|
||||
import { WordOptions } from "../types";
|
||||
|
||||
/** A randomizer backed by a CryptoService. */
|
||||
export class CryptoServiceRandomizer implements Randomizer {
|
||||
constructor(private crypto: CryptoService) {}
|
||||
|
||||
async pick<Entry>(list: Array<Entry>) {
|
||||
const index = await this.uniform(0, list.length - 1);
|
||||
return list[index];
|
||||
}
|
||||
|
||||
async pickWord(list: Array<string>, options?: WordOptions) {
|
||||
let word = await this.pick(list);
|
||||
|
||||
if (options?.titleCase ?? false) {
|
||||
word = word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}
|
||||
|
||||
if (options?.number ?? false) {
|
||||
const num = await this.crypto.randomNumber(1, 9999);
|
||||
word = word + this.zeroPad(num.toString(), 4);
|
||||
}
|
||||
|
||||
return word;
|
||||
}
|
||||
|
||||
// ref: https://stackoverflow.com/a/12646864/1090359
|
||||
async shuffle<T>(items: Array<T>, options?: { copy?: boolean }) {
|
||||
const shuffled = options?.copy ?? true ? [...items] : items;
|
||||
|
||||
for (let i = items.length - 1; i > 0; i--) {
|
||||
const j = await this.uniform(0, i);
|
||||
[items[i], items[j]] = [items[j], items[i]];
|
||||
}
|
||||
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
async chars(length: number) {
|
||||
let str = "";
|
||||
const charSet = "abcdefghijklmnopqrstuvwxyz1234567890";
|
||||
for (let i = 0; i < length; i++) {
|
||||
const randomCharIndex = await this.uniform(0, charSet.length - 1);
|
||||
str += charSet.charAt(randomCharIndex);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
async uniform(min: number, max: number) {
|
||||
return this.crypto.randomNumber(min, max);
|
||||
}
|
||||
|
||||
// ref: https://stackoverflow.com/a/10073788
|
||||
private zeroPad(number: string, width: number) {
|
||||
return number.length >= width
|
||||
? number
|
||||
: new Array(width - number.length + 1).join("0") + number;
|
||||
}
|
||||
}
|
1
libs/tools/generator/core/src/engine/index.ts
Normal file
1
libs/tools/generator/core/src/engine/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { CryptoServiceRandomizer } from "./crypto-service-randomizer";
|
11
libs/tools/generator/core/src/factories.ts
Normal file
11
libs/tools/generator/core/src/factories.ts
Normal file
@ -0,0 +1,11 @@
|
||||
// contains logic that constructs generator services dynamically given
|
||||
// a generator id.
|
||||
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
|
||||
import { Randomizer } from "./abstractions";
|
||||
import { CryptoServiceRandomizer } from "./engine/crypto-service-randomizer";
|
||||
|
||||
export function createRandomizer(cryptoService: CryptoService): Randomizer {
|
||||
return new CryptoServiceRandomizer(cryptoService);
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
export * from "./abstractions";
|
||||
export * from "./data";
|
||||
export { createRandomizer } from "./factories";
|
||||
export * as engine from "./engine";
|
||||
export * as policies from "./policies";
|
||||
export * as rx from "./rx";
|
||||
export * as services from "./services";
|
||||
export * as strategies from "./strategies";
|
||||
export * from "./types";
|
@ -0,0 +1,43 @@
|
||||
import { DefaultPolicyEvaluator } from "./default-policy-evaluator";
|
||||
|
||||
describe("Password generator options builder", () => {
|
||||
describe("policy", () => {
|
||||
it("should return an empty object", () => {
|
||||
const builder = new DefaultPolicyEvaluator();
|
||||
|
||||
expect(builder.policy).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("policyInEffect", () => {
|
||||
it("should return false", () => {
|
||||
const builder = new DefaultPolicyEvaluator();
|
||||
|
||||
expect(builder.policyInEffect).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPolicy(options)", () => {
|
||||
// All tests should freeze the options to ensure they are not modified
|
||||
it("should return the input operations without altering them", () => {
|
||||
const builder = new DefaultPolicyEvaluator();
|
||||
const options = Object.freeze({});
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions).toEqual(options);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitize(options)", () => {
|
||||
// All tests should freeze the options to ensure they are not modified
|
||||
it("should return the input options without altering them", () => {
|
||||
const builder = new DefaultPolicyEvaluator();
|
||||
const options = Object.freeze({});
|
||||
|
||||
const sanitizedOptions = builder.sanitize(options);
|
||||
|
||||
expect(sanitizedOptions).toEqual(options);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,27 @@
|
||||
import { PolicyEvaluator } from "../abstractions";
|
||||
import { NoPolicy } from "../types";
|
||||
|
||||
/** A policy evaluator that does not apply any policy */
|
||||
export class DefaultPolicyEvaluator<PolicyTarget>
|
||||
implements PolicyEvaluator<NoPolicy, PolicyTarget>
|
||||
{
|
||||
/** {@link PolicyEvaluator.policy} */
|
||||
get policy() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** {@link PolicyEvaluator.policyInEffect} */
|
||||
get policyInEffect() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** {@link PolicyEvaluator.applyPolicy} */
|
||||
applyPolicy(options: PolicyTarget) {
|
||||
return options;
|
||||
}
|
||||
|
||||
/** {@link PolicyEvaluator.sanitize} */
|
||||
sanitize(options: PolicyTarget) {
|
||||
return options;
|
||||
}
|
||||
}
|
5
libs/tools/generator/core/src/policies/index.ts
Normal file
5
libs/tools/generator/core/src/policies/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { DefaultPolicyEvaluator } from "./default-policy-evaluator";
|
||||
export { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
|
||||
export { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
|
||||
export { passphraseLeastPrivilege } from "./passphrase-least-privilege";
|
||||
export { passwordLeastPrivilege } from "./password-least-privilege";
|
@ -0,0 +1,260 @@
|
||||
import { DisabledPassphraseGeneratorPolicy, DefaultPassphraseBoundaries } from "../data";
|
||||
import { PassphraseGenerationOptions } from "../types";
|
||||
|
||||
import { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
|
||||
|
||||
describe("Password generator options builder", () => {
|
||||
describe("constructor()", () => {
|
||||
it("should set the policy object to a copy of the input policy", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
policy.minNumberWords = 10; // arbitrary change for deep equality check
|
||||
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policy).toEqual(policy);
|
||||
expect(builder.policy).not.toBe(policy);
|
||||
});
|
||||
|
||||
it("should set default boundaries when a default policy is used", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.numWords).toEqual(DefaultPassphraseBoundaries.numWords);
|
||||
});
|
||||
|
||||
it.each([1, 2])(
|
||||
"should use the default word boundaries when they are greater than `policy.minNumberWords` (= %i)",
|
||||
(minNumberWords) => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
policy.minNumberWords = minNumberWords;
|
||||
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.numWords).toEqual(DefaultPassphraseBoundaries.numWords);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([8, 12, 18])(
|
||||
"should use `policy.minNumberWords` (= %i) when it is greater than the default minimum words",
|
||||
(minNumberWords) => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
policy.minNumberWords = minNumberWords;
|
||||
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.numWords.min).toEqual(minNumberWords);
|
||||
expect(builder.numWords.max).toEqual(DefaultPassphraseBoundaries.numWords.max);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([150, 300, 9000])(
|
||||
"should use `policy.minNumberWords` (= %i) when it is greater than the default boundaries",
|
||||
(minNumberWords) => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
policy.minNumberWords = minNumberWords;
|
||||
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.numWords.min).toEqual(minNumberWords);
|
||||
expect(builder.numWords.max).toEqual(minNumberWords);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("policyInEffect", () => {
|
||||
it("should return false when the policy has no effect", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return true when the policy has a numWords greater than the default boundary", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
policy.minNumberWords = DefaultPassphraseBoundaries.numWords.min + 1;
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true when the policy has capitalize enabled", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
policy.capitalize = true;
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true when the policy has includeNumber enabled", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
policy.includeNumber = true;
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPolicy(options)", () => {
|
||||
// All tests should freeze the options to ensure they are not modified
|
||||
|
||||
it("should set `capitalize` to `false` when the policy does not override it", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({});
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.capitalize).toBe(false);
|
||||
});
|
||||
|
||||
it("should set `capitalize` to `true` when the policy overrides it", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
policy.capitalize = true;
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ capitalize: false });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.capitalize).toBe(true);
|
||||
});
|
||||
|
||||
it("should set `includeNumber` to false when the policy does not override it", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({});
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.includeNumber).toBe(false);
|
||||
});
|
||||
|
||||
it("should set `includeNumber` to true when the policy overrides it", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
policy.includeNumber = true;
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ includeNumber: false });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.includeNumber).toBe(true);
|
||||
});
|
||||
|
||||
it("should set `numWords` to the minimum value when it isn't supplied", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({});
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.numWords).toBe(builder.numWords.min);
|
||||
});
|
||||
|
||||
it.each([1, 2])(
|
||||
"should set `numWords` (= %i) to the minimum value when it is less than the minimum",
|
||||
(numWords) => {
|
||||
expect(numWords).toBeLessThan(DefaultPassphraseBoundaries.numWords.min);
|
||||
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ numWords });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.numWords).toBe(builder.numWords.min);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([3, 8, 18, 20])(
|
||||
"should set `numWords` (= %i) to the input value when it is within the boundaries",
|
||||
(numWords) => {
|
||||
expect(numWords).toBeGreaterThanOrEqual(DefaultPassphraseBoundaries.numWords.min);
|
||||
expect(numWords).toBeLessThanOrEqual(DefaultPassphraseBoundaries.numWords.max);
|
||||
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ numWords });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.numWords).toBe(numWords);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([21, 30, 50, 100])(
|
||||
"should set `numWords` (= %i) to the maximum value when it is greater than the maximum",
|
||||
(numWords) => {
|
||||
expect(numWords).toBeGreaterThan(DefaultPassphraseBoundaries.numWords.max);
|
||||
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ numWords });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.numWords).toBe(builder.numWords.max);
|
||||
},
|
||||
);
|
||||
|
||||
it("should preserve unknown properties", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
unknown: "property",
|
||||
another: "unknown property",
|
||||
}) as PassphraseGenerationOptions;
|
||||
|
||||
const sanitizedOptions: any = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.unknown).toEqual("property");
|
||||
expect(sanitizedOptions.another).toEqual("unknown property");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitize(options)", () => {
|
||||
// All tests should freeze the options to ensure they are not modified
|
||||
|
||||
it("should return the input options without altering them", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ wordSeparator: "%" });
|
||||
|
||||
const sanitizedOptions = builder.sanitize(options);
|
||||
|
||||
expect(sanitizedOptions).toEqual(options);
|
||||
});
|
||||
|
||||
it("should set `wordSeparator` to '-' when it isn't supplied and there is no policy override", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({});
|
||||
|
||||
const sanitizedOptions = builder.sanitize(options);
|
||||
|
||||
expect(sanitizedOptions.wordSeparator).toEqual("-");
|
||||
});
|
||||
|
||||
it("should leave `wordSeparator` as the empty string '' when it is the empty string", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ wordSeparator: "" });
|
||||
|
||||
const sanitizedOptions = builder.sanitize(options);
|
||||
|
||||
expect(sanitizedOptions.wordSeparator).toEqual("");
|
||||
});
|
||||
|
||||
it("should preserve unknown properties", () => {
|
||||
const policy = Object.assign({}, DisabledPassphraseGeneratorPolicy);
|
||||
const builder = new PassphraseGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
unknown: "property",
|
||||
another: "unknown property",
|
||||
}) as PassphraseGenerationOptions;
|
||||
|
||||
const sanitizedOptions: any = builder.sanitize(options);
|
||||
|
||||
expect(sanitizedOptions.unknown).toEqual("property");
|
||||
expect(sanitizedOptions.another).toEqual("unknown property");
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,100 @@
|
||||
import { PolicyEvaluator } from "../abstractions";
|
||||
import { DefaultPassphraseGenerationOptions, DefaultPassphraseBoundaries } from "../data";
|
||||
import { Boundary, PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types";
|
||||
|
||||
/** Enforces policy for passphrase generation options.
|
||||
*/
|
||||
export class PassphraseGeneratorOptionsEvaluator
|
||||
implements PolicyEvaluator<PassphraseGeneratorPolicy, PassphraseGenerationOptions>
|
||||
{
|
||||
// This design is not ideal, but it is a step towards a more robust passphrase
|
||||
// generator. Ideally, `sanitize` would be implemented on an options class,
|
||||
// and `applyPolicy` would be implemented on a policy class, "mise en place".
|
||||
//
|
||||
// The current design of the passphrase generator, unfortunately, would require
|
||||
// a substantial rewrite to make this feasible. Hopefully this change can be
|
||||
// applied when the passphrase generator is ported to rust.
|
||||
|
||||
/** Policy applied by the evaluator.
|
||||
*/
|
||||
readonly policy: PassphraseGeneratorPolicy;
|
||||
|
||||
/** Boundaries for the number of words allowed in the password.
|
||||
*/
|
||||
readonly numWords: Boundary;
|
||||
|
||||
/** Instantiates the evaluator.
|
||||
* @param policy The policy applied by the evaluator. When this conflicts with
|
||||
* the defaults, the policy takes precedence.
|
||||
*/
|
||||
constructor(policy: PassphraseGeneratorPolicy) {
|
||||
function createBoundary(value: number, defaultBoundary: Boundary): Boundary {
|
||||
const boundary = {
|
||||
min: Math.max(defaultBoundary.min, value),
|
||||
max: Math.max(defaultBoundary.max, value),
|
||||
};
|
||||
|
||||
return boundary;
|
||||
}
|
||||
|
||||
this.policy = structuredClone(policy);
|
||||
this.numWords = createBoundary(policy.minNumberWords, DefaultPassphraseBoundaries.numWords);
|
||||
}
|
||||
|
||||
/** {@link PolicyEvaluator.policyInEffect} */
|
||||
get policyInEffect(): boolean {
|
||||
const policies = [
|
||||
this.policy.capitalize,
|
||||
this.policy.includeNumber,
|
||||
this.policy.minNumberWords > DefaultPassphraseBoundaries.numWords.min,
|
||||
];
|
||||
|
||||
return policies.includes(true);
|
||||
}
|
||||
|
||||
/** Apply policy to the input options.
|
||||
* @param options The options to build from. These options are not altered.
|
||||
* @returns A new password generation request with policy applied.
|
||||
*/
|
||||
applyPolicy(options: PassphraseGenerationOptions): PassphraseGenerationOptions {
|
||||
function fitToBounds(value: number, boundaries: Boundary) {
|
||||
const { min, max } = boundaries;
|
||||
|
||||
const withUpperBound = Math.min(value ?? boundaries.min, max);
|
||||
const withLowerBound = Math.max(withUpperBound, min);
|
||||
|
||||
return withLowerBound;
|
||||
}
|
||||
|
||||
// apply policy overrides
|
||||
const capitalize = this.policy.capitalize || options.capitalize || false;
|
||||
const includeNumber = this.policy.includeNumber || options.includeNumber || false;
|
||||
|
||||
// apply boundaries
|
||||
const numWords = fitToBounds(options.numWords, this.numWords);
|
||||
|
||||
return {
|
||||
...options,
|
||||
numWords,
|
||||
capitalize,
|
||||
includeNumber,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ensures internal options consistency.
|
||||
* @param options The options to cascade. These options are not altered.
|
||||
* @returns A passphrase generation request with cascade applied.
|
||||
*/
|
||||
sanitize(options: PassphraseGenerationOptions): PassphraseGenerationOptions {
|
||||
// ensure words are separated by a single character or the empty string
|
||||
const wordSeparator =
|
||||
options.wordSeparator === ""
|
||||
? ""
|
||||
: options.wordSeparator?.[0] ?? DefaultPassphraseGenerationOptions.wordSeparator;
|
||||
|
||||
return {
|
||||
...options,
|
||||
wordSeparator,
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { PolicyId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DisabledPassphraseGeneratorPolicy } from "../data";
|
||||
|
||||
import { passphraseLeastPrivilege } from "./passphrase-least-privilege";
|
||||
|
||||
function createPolicy(
|
||||
data: any,
|
||||
type: PolicyType = PolicyType.PasswordGenerator,
|
||||
enabled: boolean = true,
|
||||
) {
|
||||
return new Policy({
|
||||
id: "id" as PolicyId,
|
||||
organizationId: "organizationId",
|
||||
data,
|
||||
enabled,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
describe("passphraseLeastPrivilege", () => {
|
||||
it("should return the accumulator when the policy type does not apply", () => {
|
||||
const policy = createPolicy({}, PolicyType.RequireSso);
|
||||
|
||||
const result = passphraseLeastPrivilege(DisabledPassphraseGeneratorPolicy, policy);
|
||||
|
||||
expect(result).toEqual(DisabledPassphraseGeneratorPolicy);
|
||||
});
|
||||
|
||||
it("should return the accumulator when the policy is not enabled", () => {
|
||||
const policy = createPolicy({}, PolicyType.PasswordGenerator, false);
|
||||
|
||||
const result = passphraseLeastPrivilege(DisabledPassphraseGeneratorPolicy, policy);
|
||||
|
||||
expect(result).toEqual(DisabledPassphraseGeneratorPolicy);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["minNumberWords", 10],
|
||||
["capitalize", true],
|
||||
["includeNumber", true],
|
||||
])("should take the %p from the policy", (input, value) => {
|
||||
const policy = createPolicy({ [input]: value });
|
||||
|
||||
const result = passphraseLeastPrivilege(DisabledPassphraseGeneratorPolicy, policy);
|
||||
|
||||
expect(result).toEqual({ ...DisabledPassphraseGeneratorPolicy, [input]: value });
|
||||
});
|
||||
});
|
@ -0,0 +1,27 @@
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
|
||||
import { PassphraseGeneratorPolicy } from "../types";
|
||||
|
||||
/** Reduces a policy into an accumulator by accepting the most restrictive
|
||||
* values from each policy.
|
||||
* @param acc the accumulator
|
||||
* @param policy the policy to reduce
|
||||
* @returns the most restrictive values between the policy and accumulator.
|
||||
*/
|
||||
export function passphraseLeastPrivilege(
|
||||
acc: PassphraseGeneratorPolicy,
|
||||
policy: Policy,
|
||||
): PassphraseGeneratorPolicy {
|
||||
if (policy.type !== PolicyType.PasswordGenerator) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
return {
|
||||
minNumberWords: Math.max(acc.minNumberWords, policy.data.minNumberWords ?? acc.minNumberWords),
|
||||
capitalize: policy.data.capitalize || acc.capitalize,
|
||||
includeNumber: policy.data.includeNumber || acc.includeNumber,
|
||||
};
|
||||
}
|
@ -0,0 +1,765 @@
|
||||
import { DefaultPasswordBoundaries, DisabledPasswordGeneratorPolicy } from "../data";
|
||||
import { PasswordGenerationOptions } from "../types";
|
||||
|
||||
import { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
|
||||
|
||||
describe("Password generator options builder", () => {
|
||||
const defaultOptions = Object.freeze({ minLength: 0 });
|
||||
|
||||
describe("constructor()", () => {
|
||||
it("should set the policy object to a copy of the input policy", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.minLength = 10; // arbitrary change for deep equality check
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policy).toEqual(policy);
|
||||
expect(builder.policy).not.toBe(policy);
|
||||
});
|
||||
|
||||
it("should set default boundaries when a default policy is used", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.length).toEqual(DefaultPasswordBoundaries.length);
|
||||
expect(builder.minDigits).toEqual(DefaultPasswordBoundaries.minDigits);
|
||||
expect(builder.minSpecialCharacters).toEqual(DefaultPasswordBoundaries.minSpecialCharacters);
|
||||
});
|
||||
|
||||
it.each([1, 2, 3, 4])(
|
||||
"should use the default length boundaries when they are greater than `policy.minLength` (= %i)",
|
||||
(minLength) => {
|
||||
expect(minLength).toBeLessThan(DefaultPasswordBoundaries.length.min);
|
||||
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.minLength = minLength;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.length).toEqual(DefaultPasswordBoundaries.length);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([8, 20, 100])(
|
||||
"should use `policy.minLength` (= %i) when it is greater than the default minimum length",
|
||||
(expectedLength) => {
|
||||
expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.min);
|
||||
expect(expectedLength).toBeLessThanOrEqual(DefaultPasswordBoundaries.length.max);
|
||||
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.minLength = expectedLength;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.length.min).toEqual(expectedLength);
|
||||
expect(builder.length.max).toEqual(DefaultPasswordBoundaries.length.max);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([150, 300, 9000])(
|
||||
"should use `policy.minLength` (= %i) when it is greater than the default boundaries",
|
||||
(expectedLength) => {
|
||||
expect(expectedLength).toBeGreaterThan(DefaultPasswordBoundaries.length.max);
|
||||
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.minLength = expectedLength;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.length.min).toEqual(expectedLength);
|
||||
expect(builder.length.max).toEqual(expectedLength);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([3, 5, 8, 9])(
|
||||
"should use `policy.numberCount` (= %i) when it is greater than the default minimum digits",
|
||||
(expectedMinDigits) => {
|
||||
expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.min);
|
||||
expect(expectedMinDigits).toBeLessThanOrEqual(DefaultPasswordBoundaries.minDigits.max);
|
||||
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.numberCount = expectedMinDigits;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.minDigits.min).toEqual(expectedMinDigits);
|
||||
expect(builder.minDigits.max).toEqual(DefaultPasswordBoundaries.minDigits.max);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([10, 20, 400])(
|
||||
"should use `policy.numberCount` (= %i) when it is greater than the default digit boundaries",
|
||||
(expectedMinDigits) => {
|
||||
expect(expectedMinDigits).toBeGreaterThan(DefaultPasswordBoundaries.minDigits.max);
|
||||
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.numberCount = expectedMinDigits;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.minDigits.min).toEqual(expectedMinDigits);
|
||||
expect(builder.minDigits.max).toEqual(expectedMinDigits);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([2, 4, 6])(
|
||||
"should use `policy.specialCount` (= %i) when it is greater than the default minimum special characters",
|
||||
(expectedSpecialCharacters) => {
|
||||
expect(expectedSpecialCharacters).toBeGreaterThan(
|
||||
DefaultPasswordBoundaries.minSpecialCharacters.min,
|
||||
);
|
||||
expect(expectedSpecialCharacters).toBeLessThanOrEqual(
|
||||
DefaultPasswordBoundaries.minSpecialCharacters.max,
|
||||
);
|
||||
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.specialCount = expectedSpecialCharacters;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters);
|
||||
expect(builder.minSpecialCharacters.max).toEqual(
|
||||
DefaultPasswordBoundaries.minSpecialCharacters.max,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([10, 20, 400])(
|
||||
"should use `policy.specialCount` (= %i) when it is greater than the default special characters boundaries",
|
||||
(expectedSpecialCharacters) => {
|
||||
expect(expectedSpecialCharacters).toBeGreaterThan(
|
||||
DefaultPasswordBoundaries.minSpecialCharacters.max,
|
||||
);
|
||||
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.specialCount = expectedSpecialCharacters;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.minSpecialCharacters.min).toEqual(expectedSpecialCharacters);
|
||||
expect(builder.minSpecialCharacters.max).toEqual(expectedSpecialCharacters);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[8, 6, 2],
|
||||
[6, 2, 4],
|
||||
[16, 8, 8],
|
||||
])(
|
||||
"should ensure the minimum length (= %i) is at least the sum of minimums (= %i + %i)",
|
||||
(expectedLength, numberCount, specialCount) => {
|
||||
expect(expectedLength).toBeGreaterThanOrEqual(DefaultPasswordBoundaries.length.min);
|
||||
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.numberCount = numberCount;
|
||||
policy.specialCount = specialCount;
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.length.min).toBeGreaterThanOrEqual(expectedLength);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("policyInEffect", () => {
|
||||
it("should return false when the policy has no effect", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(false);
|
||||
});
|
||||
|
||||
it("should return true when the policy has a minlength greater than the default boundary", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.minLength = DefaultPasswordBoundaries.length.min + 1;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true when the policy has a number count greater than the default boundary", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.numberCount = DefaultPasswordBoundaries.minDigits.min + 1;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true when the policy has a special character count greater than the default boundary", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.specialCount = DefaultPasswordBoundaries.minSpecialCharacters.min + 1;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true when the policy has uppercase enabled", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useUppercase = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true when the policy has lowercase enabled", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useLowercase = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true when the policy has numbers enabled", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useNumbers = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return true when the policy has special characters enabled", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useSpecial = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
|
||||
expect(builder.policyInEffect).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPolicy(options)", () => {
|
||||
// All tests should freeze the options to ensure they are not modified
|
||||
|
||||
it.each([
|
||||
[false, false],
|
||||
[true, true],
|
||||
[false, undefined],
|
||||
])(
|
||||
"should set `options.uppercase` to '%s' when `policy.useUppercase` is false and `options.uppercase` is '%s'",
|
||||
(expectedUppercase, uppercase) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useUppercase = false;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, uppercase });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.uppercase).toEqual(expectedUppercase);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true, undefined])(
|
||||
"should set `options.uppercase` (= %s) to true when `policy.useUppercase` is true",
|
||||
(uppercase) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useUppercase = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, uppercase });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.uppercase).toEqual(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[false, false],
|
||||
[true, true],
|
||||
[false, undefined],
|
||||
])(
|
||||
"should set `options.lowercase` to '%s' when `policy.useLowercase` is false and `options.lowercase` is '%s'",
|
||||
(expectedLowercase, lowercase) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useLowercase = false;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, lowercase });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.lowercase).toEqual(expectedLowercase);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true, undefined])(
|
||||
"should set `options.lowercase` (= %s) to true when `policy.useLowercase` is true",
|
||||
(lowercase) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useLowercase = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, lowercase });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.lowercase).toEqual(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[false, false],
|
||||
[true, true],
|
||||
[false, undefined],
|
||||
])(
|
||||
"should set `options.number` to '%s' when `policy.useNumbers` is false and `options.number` is '%s'",
|
||||
(expectedNumber, number) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useNumbers = false;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, number });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.number).toEqual(expectedNumber);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true, undefined])(
|
||||
"should set `options.number` (= %s) to true when `policy.useNumbers` is true",
|
||||
(number) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useNumbers = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, number });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.number).toEqual(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[false, false],
|
||||
[true, true],
|
||||
[false, undefined],
|
||||
])(
|
||||
"should set `options.special` to '%s' when `policy.useSpecial` is false and `options.special` is '%s'",
|
||||
(expectedSpecial, special) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useSpecial = false;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, special });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.special).toEqual(expectedSpecial);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true, undefined])(
|
||||
"should set `options.special` (= %s) to true when `policy.useSpecial` is true",
|
||||
(special) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.useSpecial = true;
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, special });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.special).toEqual(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1, 2, 3, 4])(
|
||||
"should set `options.length` (= %i) to the minimum it is less than the minimum length",
|
||||
(length) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(length).toBeLessThan(builder.length.min);
|
||||
|
||||
const options = Object.freeze({ ...defaultOptions, length });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.length).toEqual(builder.length.min);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([5, 10, 50, 100, 128])(
|
||||
"should not change `options.length` (= %i) when it is within the boundaries",
|
||||
(length) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(length).toBeGreaterThanOrEqual(builder.length.min);
|
||||
expect(length).toBeLessThanOrEqual(builder.length.max);
|
||||
|
||||
const options = Object.freeze({ ...defaultOptions, length });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.length).toEqual(length);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([129, 500, 9000])(
|
||||
"should set `options.length` (= %i) to the maximum length when it is exceeded",
|
||||
(length) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(length).toBeGreaterThan(builder.length.max);
|
||||
|
||||
const options = Object.freeze({ ...defaultOptions, length });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.length).toEqual(builder.length.max);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[true, 1],
|
||||
[true, 3],
|
||||
[true, 600],
|
||||
[false, 0],
|
||||
[false, -2],
|
||||
[false, -600],
|
||||
])(
|
||||
"should set `options.number === %s` when `options.minNumber` (= %i) is set to a value greater than 0",
|
||||
(expectedNumber, minNumber) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, minNumber });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.number).toEqual(expectedNumber);
|
||||
},
|
||||
);
|
||||
|
||||
it("should set `options.minNumber` to the minimum value when `options.number` is true", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, number: true });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min);
|
||||
});
|
||||
|
||||
it("should set `options.minNumber` to 0 when `options.number` is false", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, number: false });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minNumber).toEqual(0);
|
||||
});
|
||||
|
||||
it.each([1, 2, 3, 4])(
|
||||
"should set `options.minNumber` (= %i) to the minimum it is less than the minimum number",
|
||||
(minNumber) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.numberCount = 5; // arbitrary value greater than minNumber
|
||||
expect(minNumber).toBeLessThan(policy.numberCount);
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, minNumber });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.min);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1, 3, 5, 7, 9])(
|
||||
"should not change `options.minNumber` (= %i) when it is within the boundaries",
|
||||
(minNumber) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(minNumber).toBeGreaterThanOrEqual(builder.minDigits.min);
|
||||
expect(minNumber).toBeLessThanOrEqual(builder.minDigits.max);
|
||||
|
||||
const options = Object.freeze({ ...defaultOptions, minNumber });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minNumber).toEqual(minNumber);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([10, 20, 400])(
|
||||
"should set `options.minNumber` (= %i) to the maximum digit boundary when it is exceeded",
|
||||
(minNumber) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(minNumber).toBeGreaterThan(builder.minDigits.max);
|
||||
|
||||
const options = Object.freeze({ ...defaultOptions, minNumber });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minNumber).toEqual(builder.minDigits.max);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[true, 1],
|
||||
[true, 3],
|
||||
[true, 600],
|
||||
[false, 0],
|
||||
[false, -2],
|
||||
[false, -600],
|
||||
])(
|
||||
"should set `options.special === %s` when `options.minSpecial` (= %i) is set to a value greater than 0",
|
||||
(expectedSpecial, minSpecial) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, minSpecial });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.special).toEqual(expectedSpecial);
|
||||
},
|
||||
);
|
||||
|
||||
it("should set `options.minSpecial` to the minimum value when `options.special` is true", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, special: true });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minSpecial).toEqual(builder.minDigits.min);
|
||||
});
|
||||
|
||||
it("should set `options.minSpecial` to 0 when `options.special` is false", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, special: false });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minSpecial).toEqual(0);
|
||||
});
|
||||
|
||||
it.each([1, 2, 3, 4])(
|
||||
"should set `options.minSpecial` (= %i) to the minimum it is less than the minimum special characters",
|
||||
(minSpecial) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
policy.specialCount = 5; // arbitrary value greater than minSpecial
|
||||
expect(minSpecial).toBeLessThan(policy.specialCount);
|
||||
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ ...defaultOptions, minSpecial });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.min);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1, 3, 5, 7, 9])(
|
||||
"should not change `options.minSpecial` (= %i) when it is within the boundaries",
|
||||
(minSpecial) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(minSpecial).toBeGreaterThanOrEqual(builder.minSpecialCharacters.min);
|
||||
expect(minSpecial).toBeLessThanOrEqual(builder.minSpecialCharacters.max);
|
||||
|
||||
const options = Object.freeze({ ...defaultOptions, minSpecial });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minSpecial).toEqual(minSpecial);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([10, 20, 400])(
|
||||
"should set `options.minSpecial` (= %i) to the maximum special character boundary when it is exceeded",
|
||||
(minSpecial) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
expect(minSpecial).toBeGreaterThan(builder.minSpecialCharacters.max);
|
||||
|
||||
const options = Object.freeze({ ...defaultOptions, minSpecial });
|
||||
|
||||
const sanitizedOptions = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.minSpecial).toEqual(builder.minSpecialCharacters.max);
|
||||
},
|
||||
);
|
||||
|
||||
it("should preserve unknown properties", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
unknown: "property",
|
||||
another: "unknown property",
|
||||
}) as PasswordGenerationOptions;
|
||||
|
||||
const sanitizedOptions: any = builder.applyPolicy(options);
|
||||
|
||||
expect(sanitizedOptions.unknown).toEqual("property");
|
||||
expect(sanitizedOptions.another).toEqual("unknown property");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitize(options)", () => {
|
||||
// All tests should freeze the options to ensure they are not modified
|
||||
|
||||
it.each([
|
||||
[1, true],
|
||||
[0, false],
|
||||
])(
|
||||
"should output `options.minLowercase === %i` when `options.lowercase` is %s",
|
||||
(expectedMinLowercase, lowercase) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ lowercase, ...defaultOptions });
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.minLowercase).toEqual(expectedMinLowercase);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[1, true],
|
||||
[0, false],
|
||||
])(
|
||||
"should output `options.minUppercase === %i` when `options.uppercase` is %s",
|
||||
(expectedMinUppercase, uppercase) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ uppercase, ...defaultOptions });
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.minUppercase).toEqual(expectedMinUppercase);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[1, true],
|
||||
[0, false],
|
||||
])(
|
||||
"should output `options.minNumber === %i` when `options.number` is %s and `options.minNumber` is not set",
|
||||
(expectedMinNumber, number) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ number, ...defaultOptions });
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.minNumber).toEqual(expectedMinNumber);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[true, 3],
|
||||
[true, 2],
|
||||
[true, 1],
|
||||
[false, 0],
|
||||
])(
|
||||
"should output `options.number === %s` when `options.minNumber` is %i and `options.number` is not set",
|
||||
(expectedNumber, minNumber) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ minNumber, ...defaultOptions });
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.number).toEqual(expectedNumber);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[true, 1],
|
||||
[false, 0],
|
||||
])(
|
||||
"should output `options.minSpecial === %i` when `options.special` is %s and `options.minSpecial` is not set",
|
||||
(special, expectedMinSpecial) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ special, ...defaultOptions });
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.minSpecial).toEqual(expectedMinSpecial);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[3, true],
|
||||
[2, true],
|
||||
[1, true],
|
||||
[0, false],
|
||||
])(
|
||||
"should output `options.special === %s` when `options.minSpecial` is %i and `options.special` is not set",
|
||||
(minSpecial, expectedSpecial) => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({ minSpecial, ...defaultOptions });
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.special).toEqual(expectedSpecial);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[0, 0, 0, 0],
|
||||
[1, 1, 0, 0],
|
||||
[0, 0, 1, 1],
|
||||
[1, 1, 1, 1],
|
||||
])(
|
||||
"should set `options.minLength` to the minimum boundary when the sum of minimums (%i + %i + %i + %i) is less than the default minimum length.",
|
||||
(minLowercase, minUppercase, minNumber, minSpecial) => {
|
||||
const sumOfMinimums = minLowercase + minUppercase + minNumber + minSpecial;
|
||||
expect(sumOfMinimums).toBeLessThan(DefaultPasswordBoundaries.length.min);
|
||||
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
minLowercase,
|
||||
minUppercase,
|
||||
minNumber,
|
||||
minSpecial,
|
||||
...defaultOptions,
|
||||
});
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.minLength).toEqual(builder.length.min);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[12, 3, 3, 3, 3],
|
||||
[8, 2, 2, 2, 2],
|
||||
[9, 3, 3, 3, 0],
|
||||
])(
|
||||
"should set `options.minLength === %i` to the sum of minimums (%i + %i + %i + %i) when the sum is at least the default minimum length.",
|
||||
(expectedMinLength, minLowercase, minUppercase, minNumber, minSpecial) => {
|
||||
expect(expectedMinLength).toBeGreaterThanOrEqual(DefaultPasswordBoundaries.length.min);
|
||||
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
minLowercase,
|
||||
minUppercase,
|
||||
minNumber,
|
||||
minSpecial,
|
||||
...defaultOptions,
|
||||
});
|
||||
|
||||
const actual = builder.sanitize(options);
|
||||
|
||||
expect(actual.minLength).toEqual(expectedMinLength);
|
||||
},
|
||||
);
|
||||
|
||||
it("should preserve unknown properties", () => {
|
||||
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
|
||||
const builder = new PasswordGeneratorOptionsEvaluator(policy);
|
||||
const options = Object.freeze({
|
||||
unknown: "property",
|
||||
another: "unknown property",
|
||||
}) as PasswordGenerationOptions;
|
||||
|
||||
const sanitizedOptions: any = builder.sanitize(options);
|
||||
|
||||
expect(sanitizedOptions.unknown).toEqual("property");
|
||||
expect(sanitizedOptions.another).toEqual("unknown property");
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,157 @@
|
||||
import { PolicyEvaluator } from "../abstractions";
|
||||
import { DefaultPasswordBoundaries } from "../data";
|
||||
import { Boundary, PasswordGeneratorPolicy, PasswordGenerationOptions } from "../types";
|
||||
|
||||
/** Enforces policy for password generation.
|
||||
*/
|
||||
export class PasswordGeneratorOptionsEvaluator
|
||||
implements PolicyEvaluator<PasswordGeneratorPolicy, PasswordGenerationOptions>
|
||||
{
|
||||
// This design is not ideal, but it is a step towards a more robust password
|
||||
// generator. Ideally, `sanitize` would be implemented on an options class,
|
||||
// and `applyPolicy` would be implemented on a policy class, "mise en place".
|
||||
//
|
||||
// The current design of the password generator, unfortunately, would require
|
||||
// a substantial rewrite to make this feasible. Hopefully this change can be
|
||||
// applied when the password generator is ported to rust.
|
||||
|
||||
/** Boundaries for the password length. This is always large enough
|
||||
* to accommodate the minimum number of digits and special characters.
|
||||
*/
|
||||
readonly length: Boundary;
|
||||
|
||||
/** Boundaries for the minimum number of digits allowed in the password.
|
||||
*/
|
||||
readonly minDigits: Boundary;
|
||||
|
||||
/** Boundaries for the minimum number of special characters allowed
|
||||
* in the password.
|
||||
*/
|
||||
readonly minSpecialCharacters: Boundary;
|
||||
|
||||
/** Policy applied by the evaluator.
|
||||
*/
|
||||
readonly policy: PasswordGeneratorPolicy;
|
||||
|
||||
/** Instantiates the evaluator.
|
||||
* @param policy The policy applied by the evaluator. When this conflicts with
|
||||
* the defaults, the policy takes precedence.
|
||||
*/
|
||||
constructor(policy: PasswordGeneratorPolicy) {
|
||||
function createBoundary(value: number, defaultBoundary: Boundary): Boundary {
|
||||
const boundary = {
|
||||
min: Math.max(defaultBoundary.min, value),
|
||||
max: Math.max(defaultBoundary.max, value),
|
||||
};
|
||||
|
||||
return boundary;
|
||||
}
|
||||
|
||||
this.policy = structuredClone(policy);
|
||||
this.minDigits = createBoundary(policy.numberCount, DefaultPasswordBoundaries.minDigits);
|
||||
this.minSpecialCharacters = createBoundary(
|
||||
policy.specialCount,
|
||||
DefaultPasswordBoundaries.minSpecialCharacters,
|
||||
);
|
||||
|
||||
// the overall length should be at least as long as the sum of the minimums
|
||||
const minConsistentLength = this.minDigits.min + this.minSpecialCharacters.min;
|
||||
const minPolicyLength =
|
||||
policy.minLength > 0 ? policy.minLength : DefaultPasswordBoundaries.length.min;
|
||||
const minLength = Math.max(
|
||||
minPolicyLength,
|
||||
minConsistentLength,
|
||||
DefaultPasswordBoundaries.length.min,
|
||||
);
|
||||
|
||||
this.length = {
|
||||
min: minLength,
|
||||
max: Math.max(DefaultPasswordBoundaries.length.max, minLength),
|
||||
};
|
||||
}
|
||||
|
||||
/** {@link PolicyEvaluator.policyInEffect} */
|
||||
get policyInEffect(): boolean {
|
||||
const policies = [
|
||||
this.policy.useUppercase,
|
||||
this.policy.useLowercase,
|
||||
this.policy.useNumbers,
|
||||
this.policy.useSpecial,
|
||||
this.policy.minLength > DefaultPasswordBoundaries.length.min,
|
||||
this.policy.numberCount > DefaultPasswordBoundaries.minDigits.min,
|
||||
this.policy.specialCount > DefaultPasswordBoundaries.minSpecialCharacters.min,
|
||||
];
|
||||
|
||||
return policies.includes(true);
|
||||
}
|
||||
|
||||
/** {@link PolicyEvaluator.applyPolicy} */
|
||||
applyPolicy(options: PasswordGenerationOptions): PasswordGenerationOptions {
|
||||
function fitToBounds(value: number, boundaries: Boundary) {
|
||||
const { min, max } = boundaries;
|
||||
|
||||
const withUpperBound = Math.min(value || 0, max);
|
||||
const withLowerBound = Math.max(withUpperBound, min);
|
||||
|
||||
return withLowerBound;
|
||||
}
|
||||
|
||||
// apply policy overrides
|
||||
const uppercase = this.policy.useUppercase || options.uppercase || false;
|
||||
const lowercase = this.policy.useLowercase || options.lowercase || false;
|
||||
|
||||
// these overrides can cascade numeric fields to boolean fields
|
||||
const number = this.policy.useNumbers || options.number || options.minNumber > 0;
|
||||
const special = this.policy.useSpecial || options.special || options.minSpecial > 0;
|
||||
|
||||
// apply boundaries; the boundaries can cascade boolean fields to numeric fields
|
||||
const length = fitToBounds(options.length, this.length);
|
||||
const minNumber = fitToBounds(options.minNumber, this.minDigits);
|
||||
const minSpecial = fitToBounds(options.minSpecial, this.minSpecialCharacters);
|
||||
|
||||
return {
|
||||
...options,
|
||||
length,
|
||||
uppercase,
|
||||
lowercase,
|
||||
number,
|
||||
minNumber,
|
||||
special,
|
||||
minSpecial,
|
||||
};
|
||||
}
|
||||
|
||||
/** {@link PolicyEvaluator.sanitize} */
|
||||
sanitize(options: PasswordGenerationOptions): PasswordGenerationOptions {
|
||||
function cascade(enabled: boolean, value: number): [boolean, number] {
|
||||
const enabledResult = enabled ?? value > 0;
|
||||
const valueResult = enabledResult ? value || 1 : 0;
|
||||
|
||||
return [enabledResult, valueResult];
|
||||
}
|
||||
|
||||
const [lowercase, minLowercase] = cascade(options.lowercase, options.minLowercase);
|
||||
const [uppercase, minUppercase] = cascade(options.uppercase, options.minUppercase);
|
||||
const [number, minNumber] = cascade(options.number, options.minNumber);
|
||||
const [special, minSpecial] = cascade(options.special, options.minSpecial);
|
||||
|
||||
// minimums can only increase the length
|
||||
const minConsistentLength = minLowercase + minUppercase + minNumber + minSpecial;
|
||||
const minLength = Math.max(minConsistentLength, this.length.min);
|
||||
const length = Math.max(options.length ?? minLength, minLength);
|
||||
|
||||
return {
|
||||
...options,
|
||||
length,
|
||||
minLength,
|
||||
lowercase,
|
||||
minLowercase,
|
||||
uppercase,
|
||||
minUppercase,
|
||||
number,
|
||||
minNumber,
|
||||
special,
|
||||
minSpecial,
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { PolicyId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { DisabledPasswordGeneratorPolicy } from "../data";
|
||||
|
||||
import { passwordLeastPrivilege } from "./password-least-privilege";
|
||||
|
||||
function createPolicy(
|
||||
data: any,
|
||||
type: PolicyType = PolicyType.PasswordGenerator,
|
||||
enabled: boolean = true,
|
||||
) {
|
||||
return new Policy({
|
||||
id: "id" as PolicyId,
|
||||
organizationId: "organizationId",
|
||||
data,
|
||||
enabled,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
describe("passwordLeastPrivilege", () => {
|
||||
it("should return the accumulator when the policy type does not apply", () => {
|
||||
const policy = createPolicy({}, PolicyType.RequireSso);
|
||||
|
||||
const result = passwordLeastPrivilege(DisabledPasswordGeneratorPolicy, policy);
|
||||
|
||||
expect(result).toEqual(DisabledPasswordGeneratorPolicy);
|
||||
});
|
||||
|
||||
it("should return the accumulator when the policy is not enabled", () => {
|
||||
const policy = createPolicy({}, PolicyType.PasswordGenerator, false);
|
||||
|
||||
const result = passwordLeastPrivilege(DisabledPasswordGeneratorPolicy, policy);
|
||||
|
||||
expect(result).toEqual(DisabledPasswordGeneratorPolicy);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["minLength", 10, "minLength"],
|
||||
["useUpper", true, "useUppercase"],
|
||||
["useLower", true, "useLowercase"],
|
||||
["useNumbers", true, "useNumbers"],
|
||||
["minNumbers", 10, "numberCount"],
|
||||
["useSpecial", true, "useSpecial"],
|
||||
["minSpecial", 10, "specialCount"],
|
||||
])("should take the %p from the policy", (input, value, expected) => {
|
||||
const policy = createPolicy({ [input]: value });
|
||||
|
||||
const result = passwordLeastPrivilege(DisabledPasswordGeneratorPolicy, policy);
|
||||
|
||||
expect(result).toEqual({ ...DisabledPasswordGeneratorPolicy, [expected]: value });
|
||||
});
|
||||
});
|
@ -0,0 +1,28 @@
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
|
||||
import { PasswordGeneratorPolicy } from "../types";
|
||||
|
||||
/** Reduces a policy into an accumulator by accepting the most restrictive
|
||||
* values from each policy.
|
||||
* @param acc the accumulator
|
||||
* @param policy the policy to reduce
|
||||
* @returns the most restrictive values between the policy and accumulator.
|
||||
*/
|
||||
export function passwordLeastPrivilege(acc: PasswordGeneratorPolicy, policy: Policy) {
|
||||
if (policy.type !== PolicyType.PasswordGenerator || !policy.enabled) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
return {
|
||||
minLength: Math.max(acc.minLength, policy.data.minLength ?? acc.minLength),
|
||||
useUppercase: policy.data.useUpper || acc.useUppercase,
|
||||
useLowercase: policy.data.useLower || acc.useLowercase,
|
||||
useNumbers: policy.data.useNumbers || acc.useNumbers,
|
||||
numberCount: Math.max(acc.numberCount, policy.data.minNumbers ?? acc.numberCount),
|
||||
useSpecial: policy.data.useSpecial || acc.useSpecial,
|
||||
specialCount: Math.max(acc.specialCount, policy.data.minSpecial ?? acc.specialCount),
|
||||
};
|
||||
}
|
26
libs/tools/generator/core/src/rx.ts
Normal file
26
libs/tools/generator/core/src/rx.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { map, pipe } from "rxjs";
|
||||
|
||||
import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx";
|
||||
|
||||
import { DefaultPolicyEvaluator } from "./policies";
|
||||
import { PolicyConfiguration } from "./types";
|
||||
|
||||
/** Maps an administrative console policy to a policy evaluator using the provided configuration.
|
||||
* @param configuration the configuration that constructs the evaluator.
|
||||
*/
|
||||
export function mapPolicyToEvaluator<Policy, Evaluator>(
|
||||
configuration: PolicyConfiguration<Policy, Evaluator>,
|
||||
) {
|
||||
return pipe(
|
||||
reduceCollection(configuration.combine, configuration.disabledValue),
|
||||
distinctIfShallowMatch(),
|
||||
map(configuration.createEvaluator),
|
||||
);
|
||||
}
|
||||
|
||||
/** Constructs a method that maps a policy to the default (no-op) policy. */
|
||||
export function newDefaultEvaluator<Target>() {
|
||||
return () => {
|
||||
return pipe(map((_) => new DefaultPolicyEvaluator<Target>()));
|
||||
};
|
||||
}
|
@ -0,0 +1,194 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, map, pipe } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { SingleUserState } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { FakeSingleUserState, awaitAsync } from "../../../../../common/spec";
|
||||
import { GeneratorStrategy, PolicyEvaluator } from "../abstractions";
|
||||
import { PasswordGenerationOptions } from "../types";
|
||||
|
||||
import { DefaultGeneratorService } from "./default-generator.service";
|
||||
|
||||
function mockPolicyService(config?: { state?: BehaviorSubject<Policy[]> }) {
|
||||
const service = mock<PolicyService>();
|
||||
|
||||
const stateValue = config?.state ?? new BehaviorSubject<Policy[]>([null]);
|
||||
service.getAll$.mockReturnValue(stateValue);
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
function mockGeneratorStrategy(config?: {
|
||||
userState?: SingleUserState<any>;
|
||||
policy?: PolicyType;
|
||||
evaluator?: any;
|
||||
defaults?: any;
|
||||
}) {
|
||||
const durableState =
|
||||
config?.userState ?? new FakeSingleUserState<PasswordGenerationOptions>(SomeUser);
|
||||
const strategy = mock<GeneratorStrategy<any, any>>({
|
||||
// intentionally arbitrary so that tests that need to check
|
||||
// whether they're used properly are guaranteed to test
|
||||
// the value from `config`.
|
||||
durableState: jest.fn(() => durableState),
|
||||
defaults$: jest.fn(() => new BehaviorSubject(config?.defaults)),
|
||||
policy: config?.policy ?? PolicyType.DisableSend,
|
||||
toEvaluator: jest.fn(() =>
|
||||
pipe(map(() => config?.evaluator ?? mock<PolicyEvaluator<any, any>>())),
|
||||
),
|
||||
});
|
||||
|
||||
return strategy;
|
||||
}
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
const AnotherUser = "another user" as UserId;
|
||||
|
||||
describe("Password generator service", () => {
|
||||
describe("options$", () => {
|
||||
it("should retrieve durable state from the service", () => {
|
||||
const policy = mockPolicyService();
|
||||
const userState = new FakeSingleUserState<PasswordGenerationOptions>(SomeUser);
|
||||
const strategy = mockGeneratorStrategy({ userState });
|
||||
const service = new DefaultGeneratorService(strategy, policy);
|
||||
|
||||
const result = service.options$(SomeUser);
|
||||
|
||||
expect(strategy.durableState).toHaveBeenCalledWith(SomeUser);
|
||||
expect(result).toBe(userState.state$);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaults$", () => {
|
||||
it("should retrieve default state from the service", async () => {
|
||||
const policy = mockPolicyService();
|
||||
const defaults = {};
|
||||
const strategy = mockGeneratorStrategy({ defaults });
|
||||
const service = new DefaultGeneratorService(strategy, policy);
|
||||
|
||||
const result = await firstValueFrom(service.defaults$(SomeUser));
|
||||
|
||||
expect(strategy.defaults$).toHaveBeenCalledWith(SomeUser);
|
||||
expect(result).toBe(defaults);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveOptions()", () => {
|
||||
it("should trigger an options$ update", async () => {
|
||||
const policy = mockPolicyService();
|
||||
const userState = new FakeSingleUserState<PasswordGenerationOptions>(SomeUser, { length: 9 });
|
||||
const strategy = mockGeneratorStrategy({ userState });
|
||||
const service = new DefaultGeneratorService(strategy, policy);
|
||||
|
||||
await service.saveOptions(SomeUser, { length: 10 });
|
||||
await awaitAsync();
|
||||
const options = await firstValueFrom(service.options$(SomeUser));
|
||||
|
||||
expect(strategy.durableState).toHaveBeenCalledWith(SomeUser);
|
||||
expect(options).toEqual({ length: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluator$", () => {
|
||||
it("should initialize the password generator policy", async () => {
|
||||
const policy = mockPolicyService();
|
||||
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
|
||||
const service = new DefaultGeneratorService(strategy, policy);
|
||||
|
||||
await firstValueFrom(service.evaluator$(SomeUser));
|
||||
|
||||
expect(policy.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser);
|
||||
});
|
||||
|
||||
it("should map the policy using the generation strategy", async () => {
|
||||
const policyService = mockPolicyService();
|
||||
const evaluator = mock<PolicyEvaluator<any, any>>();
|
||||
const strategy = mockGeneratorStrategy({ evaluator });
|
||||
const service = new DefaultGeneratorService(strategy, policyService);
|
||||
|
||||
const policy = await firstValueFrom(service.evaluator$(SomeUser));
|
||||
|
||||
expect(policy).toBe(evaluator);
|
||||
});
|
||||
|
||||
it("should update the evaluator when the password generator policy changes", async () => {
|
||||
// set up dependencies
|
||||
const state = new BehaviorSubject<Policy[]>([null]);
|
||||
const policy = mockPolicyService({ state });
|
||||
const strategy = mockGeneratorStrategy();
|
||||
const service = new DefaultGeneratorService(strategy, policy);
|
||||
|
||||
// model responses for the observable update. The map is called multiple times,
|
||||
// and the array shift ensures reference equality is maintained.
|
||||
const firstEvaluator = mock<PolicyEvaluator<any, any>>();
|
||||
const secondEvaluator = mock<PolicyEvaluator<any, any>>();
|
||||
const evaluators = [firstEvaluator, secondEvaluator];
|
||||
strategy.toEvaluator.mockReturnValueOnce(pipe(map(() => evaluators.shift())));
|
||||
|
||||
// act
|
||||
const evaluator$ = service.evaluator$(SomeUser);
|
||||
const firstResult = await firstValueFrom(evaluator$);
|
||||
state.next([null]);
|
||||
const secondResult = await firstValueFrom(evaluator$);
|
||||
|
||||
// assert
|
||||
expect(firstResult).toBe(firstEvaluator);
|
||||
expect(secondResult).toBe(secondEvaluator);
|
||||
});
|
||||
|
||||
it("should cache the password generator policy", async () => {
|
||||
const policy = mockPolicyService();
|
||||
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
|
||||
const service = new DefaultGeneratorService(strategy, policy);
|
||||
|
||||
await firstValueFrom(service.evaluator$(SomeUser));
|
||||
await firstValueFrom(service.evaluator$(SomeUser));
|
||||
|
||||
expect(policy.getAll$).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should cache the password generator policy for each user", async () => {
|
||||
const policy = mockPolicyService();
|
||||
const strategy = mockGeneratorStrategy({ policy: PolicyType.PasswordGenerator });
|
||||
const service = new DefaultGeneratorService(strategy, policy);
|
||||
|
||||
await firstValueFrom(service.evaluator$(SomeUser));
|
||||
await firstValueFrom(service.evaluator$(AnotherUser));
|
||||
|
||||
expect(policy.getAll$).toHaveBeenNthCalledWith(1, PolicyType.PasswordGenerator, SomeUser);
|
||||
expect(policy.getAll$).toHaveBeenNthCalledWith(2, PolicyType.PasswordGenerator, AnotherUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enforcePolicy()", () => {
|
||||
it("should evaluate the policy using the generation strategy", async () => {
|
||||
const policy = mockPolicyService();
|
||||
const evaluator = mock<PolicyEvaluator<any, any>>();
|
||||
const strategy = mockGeneratorStrategy({ evaluator });
|
||||
const service = new DefaultGeneratorService(strategy, policy);
|
||||
|
||||
await service.enforcePolicy(SomeUser, {});
|
||||
|
||||
expect(evaluator.applyPolicy).toHaveBeenCalled();
|
||||
expect(evaluator.sanitize).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate()", () => {
|
||||
it("should invoke the generation strategy", async () => {
|
||||
const strategy = mockGeneratorStrategy();
|
||||
const policy = mockPolicyService();
|
||||
const service = new DefaultGeneratorService(strategy, policy);
|
||||
|
||||
await service.generate({});
|
||||
|
||||
expect(strategy.generate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,96 @@
|
||||
import { firstValueFrom, share, timer, ReplaySubject, Observable } from "rxjs";
|
||||
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { GeneratorStrategy, GeneratorService, PolicyEvaluator } from "../abstractions";
|
||||
|
||||
type DefaultGeneratorServiceTuning = {
|
||||
/* amount of time to keep the most recent policy after a subscription ends. Once the
|
||||
* cache expires, the ignoreQty and timeoutMs settings apply to the next lookup.
|
||||
*/
|
||||
policyCacheMs: number;
|
||||
};
|
||||
|
||||
/** {@link GeneratorServiceAbstraction} */
|
||||
export class DefaultGeneratorService<Options, Policy> implements GeneratorService<Options, Policy> {
|
||||
/** Instantiates the generator service
|
||||
* @param strategy tailors the service to a specific generator type
|
||||
* (e.g. password, passphrase)
|
||||
* @param policy provides the policy to enforce
|
||||
*/
|
||||
constructor(
|
||||
private strategy: GeneratorStrategy<Options, Policy>,
|
||||
private policy: PolicyService,
|
||||
tuning: Partial<DefaultGeneratorServiceTuning> = {},
|
||||
) {
|
||||
this.tuning = Object.assign(
|
||||
{
|
||||
// a minute
|
||||
policyCacheMs: 60000,
|
||||
},
|
||||
tuning,
|
||||
);
|
||||
}
|
||||
|
||||
private tuning: DefaultGeneratorServiceTuning;
|
||||
private _evaluators$ = new Map<UserId, Observable<PolicyEvaluator<Policy, Options>>>();
|
||||
|
||||
/** {@link GeneratorService.options$} */
|
||||
options$(userId: UserId) {
|
||||
return this.strategy.durableState(userId).state$;
|
||||
}
|
||||
|
||||
/** {@link GeneratorService.defaults$} */
|
||||
defaults$(userId: UserId) {
|
||||
return this.strategy.defaults$(userId);
|
||||
}
|
||||
|
||||
/** {@link GeneratorService.saveOptions} */
|
||||
async saveOptions(userId: UserId, options: Options): Promise<void> {
|
||||
await this.strategy.durableState(userId).update(() => options);
|
||||
}
|
||||
|
||||
/** {@link GeneratorService.evaluator$} */
|
||||
evaluator$(userId: UserId) {
|
||||
let evaluator$ = this._evaluators$.get(userId);
|
||||
|
||||
if (!evaluator$) {
|
||||
evaluator$ = this.createEvaluator(userId);
|
||||
this._evaluators$.set(userId, evaluator$);
|
||||
}
|
||||
|
||||
return evaluator$;
|
||||
}
|
||||
|
||||
private createEvaluator(userId: UserId) {
|
||||
const evaluator$ = this.policy.getAll$(this.strategy.policy, userId).pipe(
|
||||
// create the evaluator from the policies
|
||||
this.strategy.toEvaluator(),
|
||||
|
||||
// cache evaluator in a replay subject to amortize creation cost
|
||||
// and reduce GC pressure.
|
||||
share({
|
||||
connector: () => new ReplaySubject(1),
|
||||
resetOnRefCountZero: () => timer(this.tuning.policyCacheMs),
|
||||
}),
|
||||
);
|
||||
|
||||
return evaluator$;
|
||||
}
|
||||
|
||||
/** {@link GeneratorService.enforcePolicy} */
|
||||
async enforcePolicy(userId: UserId, options: Options): Promise<Options> {
|
||||
const policy = await firstValueFrom(this.evaluator$(userId));
|
||||
const evaluated = policy.applyPolicy(options);
|
||||
const sanitized = policy.sanitize(evaluated);
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/** {@link GeneratorService.generate} */
|
||||
async generate(options: Options): Promise<string> {
|
||||
return await this.strategy.generate(options);
|
||||
}
|
||||
}
|
1
libs/tools/generator/core/src/services/index.ts
Normal file
1
libs/tools/generator/core/src/services/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { DefaultGeneratorService } from "./default-generator.service";
|
@ -0,0 +1,75 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of, firstValueFrom } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { Randomizer } from "../abstractions";
|
||||
import { DefaultCatchallOptions } from "../data";
|
||||
import { DefaultPolicyEvaluator } from "../policies";
|
||||
|
||||
import { CatchallGeneratorStrategy } from "./catchall-generator-strategy";
|
||||
import { CATCHALL_SETTINGS } from "./storage";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
const SomePolicy = mock<Policy>({
|
||||
type: PolicyType.PasswordGenerator,
|
||||
data: {
|
||||
minLength: 10,
|
||||
},
|
||||
});
|
||||
|
||||
describe("Email subaddress list generation strategy", () => {
|
||||
describe("toEvaluator()", () => {
|
||||
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
|
||||
"should map any input (= %p) to the default policy evaluator",
|
||||
async (policies) => {
|
||||
const strategy = new CatchallGeneratorStrategy(null, null);
|
||||
|
||||
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
|
||||
const evaluator = await firstValueFrom(evaluator$);
|
||||
|
||||
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("durableState", () => {
|
||||
it("should use password settings key", () => {
|
||||
const provider = mock<StateProvider>();
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new CatchallGeneratorStrategy(randomizer, provider);
|
||||
|
||||
strategy.durableState(SomeUser);
|
||||
|
||||
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, CATCHALL_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaults$", () => {
|
||||
it("should return the default subaddress options", async () => {
|
||||
const strategy = new CatchallGeneratorStrategy(null, null);
|
||||
|
||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
||||
|
||||
expect(result).toEqual(DefaultCatchallOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy", () => {
|
||||
it("should use password generator policy", () => {
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new CatchallGeneratorStrategy(randomizer, null);
|
||||
|
||||
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate()", () => {
|
||||
it.todo("generate catchall email addresses");
|
||||
});
|
||||
});
|
@ -0,0 +1,50 @@
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { GeneratorStrategy, Randomizer } from "../abstractions";
|
||||
import { DefaultCatchallOptions } from "../data";
|
||||
import { newDefaultEvaluator } from "../rx";
|
||||
import { NoPolicy, CatchallGenerationOptions } from "../types";
|
||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
||||
|
||||
import { CATCHALL_SETTINGS } from "./storage";
|
||||
|
||||
/** Strategy for creating usernames using a catchall email address */
|
||||
export class CatchallGeneratorStrategy
|
||||
implements GeneratorStrategy<CatchallGenerationOptions, NoPolicy>
|
||||
{
|
||||
/** Instantiates the generation strategy
|
||||
* @param usernameService generates a catchall address for a domain
|
||||
*/
|
||||
constructor(
|
||||
private random: Randomizer,
|
||||
private stateProvider: StateProvider,
|
||||
private defaultOptions: CatchallGenerationOptions = DefaultCatchallOptions,
|
||||
) {}
|
||||
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(CATCHALL_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
||||
toEvaluator = newDefaultEvaluator<CatchallGenerationOptions>();
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
|
||||
// algorithm
|
||||
async generate(options: CatchallGenerationOptions) {
|
||||
const o = Object.assign({}, DefaultCatchallOptions, options);
|
||||
|
||||
if (o.catchallDomain == null || o.catchallDomain === "") {
|
||||
return null;
|
||||
}
|
||||
if (o.catchallType == null) {
|
||||
o.catchallType = "random";
|
||||
}
|
||||
|
||||
let startString = "";
|
||||
if (o.catchallType === "random") {
|
||||
startString = await this.random.chars(8);
|
||||
} else if (o.catchallType === "website-name") {
|
||||
startString = o.website;
|
||||
}
|
||||
return startString + "@" + o.catchallDomain;
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of, firstValueFrom } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { Randomizer } from "../abstractions";
|
||||
import { DefaultEffUsernameOptions } from "../data";
|
||||
import { DefaultPolicyEvaluator } from "../policies";
|
||||
|
||||
import { EffUsernameGeneratorStrategy } from "./eff-username-generator-strategy";
|
||||
import { EFF_USERNAME_SETTINGS } from "./storage";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
const SomePolicy = mock<Policy>({
|
||||
type: PolicyType.PasswordGenerator,
|
||||
data: {
|
||||
minLength: 10,
|
||||
},
|
||||
});
|
||||
|
||||
describe("EFF long word list generation strategy", () => {
|
||||
describe("toEvaluator()", () => {
|
||||
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
|
||||
"should map any input (= %p) to the default policy evaluator",
|
||||
async (policies) => {
|
||||
const strategy = new EffUsernameGeneratorStrategy(null, null);
|
||||
|
||||
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
|
||||
const evaluator = await firstValueFrom(evaluator$);
|
||||
|
||||
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("durableState", () => {
|
||||
it("should use password settings key", () => {
|
||||
const provider = mock<StateProvider>();
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new EffUsernameGeneratorStrategy(randomizer, provider);
|
||||
|
||||
strategy.durableState(SomeUser);
|
||||
|
||||
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, EFF_USERNAME_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaults$", () => {
|
||||
it("should return the default subaddress options", async () => {
|
||||
const strategy = new EffUsernameGeneratorStrategy(null, null);
|
||||
|
||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
||||
|
||||
expect(result).toEqual(DefaultEffUsernameOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy", () => {
|
||||
it("should use password generator policy", () => {
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new EffUsernameGeneratorStrategy(randomizer, null);
|
||||
|
||||
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate()", () => {
|
||||
it.todo("generate username tests");
|
||||
});
|
||||
});
|
@ -0,0 +1,40 @@
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { GeneratorStrategy, Randomizer } from "../abstractions";
|
||||
import { DefaultEffUsernameOptions } from "../data";
|
||||
import { newDefaultEvaluator } from "../rx";
|
||||
import { EffUsernameGenerationOptions, NoPolicy } from "../types";
|
||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
||||
|
||||
import { EFF_USERNAME_SETTINGS } from "./storage";
|
||||
|
||||
/** Strategy for creating usernames from the EFF wordlist */
|
||||
export class EffUsernameGeneratorStrategy
|
||||
implements GeneratorStrategy<EffUsernameGenerationOptions, NoPolicy>
|
||||
{
|
||||
/** Instantiates the generation strategy
|
||||
* @param usernameService generates a username from EFF word list
|
||||
*/
|
||||
constructor(
|
||||
private random: Randomizer,
|
||||
private stateProvider: StateProvider,
|
||||
private defaultOptions: EffUsernameGenerationOptions = DefaultEffUsernameOptions,
|
||||
) {}
|
||||
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(EFF_USERNAME_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
||||
toEvaluator = newDefaultEvaluator<EffUsernameGenerationOptions>();
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
|
||||
// algorithm
|
||||
async generate(options: EffUsernameGenerationOptions) {
|
||||
const word = await this.random.pickWord(EFFLongWordList, {
|
||||
titleCase: options.wordCapitalize ?? DefaultEffUsernameOptions.wordCapitalize,
|
||||
number: options.wordIncludeNumber ?? DefaultEffUsernameOptions.wordIncludeNumber,
|
||||
});
|
||||
return word;
|
||||
}
|
||||
}
|
@ -0,0 +1,110 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of, firstValueFrom } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
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 { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { BufferedState } from "@bitwarden/common/tools/state/buffered-state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../common/spec";
|
||||
import { DefaultDuckDuckGoOptions } from "../data";
|
||||
import { DefaultPolicyEvaluator } from "../policies";
|
||||
import { ApiOptions } from "../types";
|
||||
|
||||
import { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy";
|
||||
import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "./storage";
|
||||
|
||||
class TestForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
|
||||
constructor(
|
||||
encryptService: EncryptService,
|
||||
keyService: CryptoService,
|
||||
stateProvider: StateProvider,
|
||||
) {
|
||||
super(encryptService, keyService, stateProvider, { website: null, token: "" });
|
||||
}
|
||||
|
||||
get key() {
|
||||
// arbitrary.
|
||||
return DUCK_DUCK_GO_FORWARDER;
|
||||
}
|
||||
|
||||
get rolloverKey() {
|
||||
return DUCK_DUCK_GO_BUFFER;
|
||||
}
|
||||
|
||||
defaults$ = (userId: UserId) => {
|
||||
return of(DefaultDuckDuckGoOptions);
|
||||
};
|
||||
}
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
const AnotherUser = "another user" as UserId;
|
||||
const SomePolicy = mock<Policy>({
|
||||
type: PolicyType.PasswordGenerator,
|
||||
data: {
|
||||
minLength: 10,
|
||||
},
|
||||
});
|
||||
|
||||
describe("ForwarderGeneratorStrategy", () => {
|
||||
const encryptService = mock<EncryptService>();
|
||||
const keyService = mock<CryptoService>();
|
||||
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
|
||||
|
||||
beforeEach(() => {
|
||||
const keyAvailable = of({} as UserKey);
|
||||
keyService.getInMemoryUserKeyFor$.mockReturnValue(keyAvailable);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("durableState", () => {
|
||||
it("constructs a secret state", () => {
|
||||
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
|
||||
|
||||
const result = strategy.durableState(SomeUser);
|
||||
|
||||
expect(result).toBeInstanceOf(BufferedState);
|
||||
});
|
||||
|
||||
it("returns the same secret state for a single user", () => {
|
||||
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
|
||||
|
||||
const firstResult = strategy.durableState(SomeUser);
|
||||
const secondResult = strategy.durableState(SomeUser);
|
||||
|
||||
expect(firstResult).toBe(secondResult);
|
||||
});
|
||||
|
||||
it("returns a different secret state for a different user", () => {
|
||||
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
|
||||
|
||||
const firstResult = strategy.durableState(SomeUser);
|
||||
const secondResult = strategy.durableState(AnotherUser);
|
||||
|
||||
expect(firstResult).not.toBe(secondResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toEvaluator()", () => {
|
||||
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
|
||||
"should map any input (= %p) to the default policy evaluator",
|
||||
async (policies) => {
|
||||
const strategy = new TestForwarder(encryptService, keyService, stateProvider);
|
||||
|
||||
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
|
||||
const evaluator = await firstValueFrom(evaluator$);
|
||||
|
||||
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
@ -0,0 +1,95 @@
|
||||
import { map } from "rxjs";
|
||||
|
||||
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 {
|
||||
SingleUserState,
|
||||
StateProvider,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||
import { BufferedState } from "@bitwarden/common/tools/state/buffered-state";
|
||||
import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer";
|
||||
import { SecretClassifier } from "@bitwarden/common/tools/state/secret-classifier";
|
||||
import { SecretKeyDefinition } from "@bitwarden/common/tools/state/secret-key-definition";
|
||||
import { SecretState } from "@bitwarden/common/tools/state/secret-state";
|
||||
import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { GeneratorStrategy } from "../abstractions";
|
||||
import { newDefaultEvaluator } from "../rx";
|
||||
import { ApiOptions, NoPolicy } from "../types";
|
||||
import { clone$PerUserId, sharedByUserId } from "../util";
|
||||
|
||||
const OPTIONS_FRAME_SIZE = 512;
|
||||
|
||||
/** An email forwarding service configurable through an API. */
|
||||
export abstract class ForwarderGeneratorStrategy<
|
||||
Options extends ApiOptions,
|
||||
> extends GeneratorStrategy<Options, NoPolicy> {
|
||||
/** Initializes the generator strategy
|
||||
* @param encryptService protects sensitive forwarder options
|
||||
* @param keyService looks up the user key when protecting data.
|
||||
* @param stateProvider creates the durable state for options storage
|
||||
*/
|
||||
constructor(
|
||||
private readonly encryptService: EncryptService,
|
||||
private readonly keyService: CryptoService,
|
||||
private stateProvider: StateProvider,
|
||||
private readonly defaultOptions: Options,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/** configures forwarder secret storage */
|
||||
protected abstract readonly key: UserKeyDefinition<Options>;
|
||||
|
||||
/** configures forwarder import buffer */
|
||||
protected abstract readonly rolloverKey: BufferedKeyDefinition<Options, Options>;
|
||||
|
||||
// configuration
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
||||
toEvaluator = newDefaultEvaluator<Options>();
|
||||
durableState = sharedByUserId((userId) => this.getUserSecrets(userId));
|
||||
|
||||
// per-user encrypted state
|
||||
private getUserSecrets(userId: UserId): SingleUserState<Options> {
|
||||
// construct the encryptor
|
||||
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE);
|
||||
const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer);
|
||||
|
||||
// always exclude request properties
|
||||
const classifier = SecretClassifier.allSecret<Options>().exclude("website");
|
||||
|
||||
// Derive the secret key definition
|
||||
const key = SecretKeyDefinition.value(this.key.stateDefinition, this.key.key, classifier, {
|
||||
deserializer: (d) => this.key.deserializer(d),
|
||||
cleanupDelayMs: this.key.cleanupDelayMs,
|
||||
clearOn: this.key.clearOn,
|
||||
});
|
||||
|
||||
// the type parameter is explicit because type inference fails for `Omit<Options, "website">`
|
||||
const secretState = SecretState.from<
|
||||
Options,
|
||||
void,
|
||||
Options,
|
||||
Record<keyof Options, never>,
|
||||
Omit<Options, "website">
|
||||
>(userId, key, this.stateProvider, encryptor);
|
||||
|
||||
// rollover should occur once the user key is available for decryption
|
||||
const canDecrypt$ = this.keyService
|
||||
.getInMemoryUserKeyFor$(userId)
|
||||
.pipe(map((key) => key !== null));
|
||||
const rolloverState = new BufferedState(
|
||||
this.stateProvider,
|
||||
this.rolloverKey,
|
||||
secretState,
|
||||
canDecrypt$,
|
||||
);
|
||||
|
||||
return rolloverState;
|
||||
}
|
||||
}
|
@ -0,0 +1,233 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { Forwarders, DefaultAddyIoOptions } from "../../data";
|
||||
import { ADDY_IO_FORWARDER } from "../storage";
|
||||
|
||||
import { AddyIoForwarder } from "./addy-io";
|
||||
import { mockApiService, mockI18nService } from "./mocks.jest";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
|
||||
describe("Addy.io Forwarder", () => {
|
||||
it("key returns the Addy IO forwarder key", () => {
|
||||
const forwarder = new AddyIoForwarder(null, null, null, null, null);
|
||||
|
||||
expect(forwarder.key).toBe(ADDY_IO_FORWARDER);
|
||||
});
|
||||
|
||||
describe("defaults$", () => {
|
||||
it("should return the default subaddress options", async () => {
|
||||
const strategy = new AddyIoForwarder(null, null, null, null, null);
|
||||
|
||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
||||
|
||||
expect(result).toEqual(DefaultAddyIoOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
|
||||
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
|
||||
const apiService = mockApiService(200, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token,
|
||||
domain: "example.com",
|
||||
baseUrl: "https://api.example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwaderInvalidToken");
|
||||
|
||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
||||
expect(i18nService.t).toHaveBeenCalledWith("forwaderInvalidToken", Forwarders.AddyIo.name);
|
||||
});
|
||||
|
||||
it.each([null, ""])(
|
||||
"throws an error if the domain is missing (domain = %p)",
|
||||
async (domain) => {
|
||||
const apiService = mockApiService(200, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain,
|
||||
baseUrl: "https://api.example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwarderNoDomain");
|
||||
|
||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
||||
expect(i18nService.t).toHaveBeenCalledWith("forwarderNoDomain", Forwarders.AddyIo.name);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, ""])(
|
||||
"throws an error if the baseUrl is missing (baseUrl = %p)",
|
||||
async (baseUrl) => {
|
||||
const apiService = mockApiService(200, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
baseUrl,
|
||||
}),
|
||||
).rejects.toEqual("forwarderNoUrl");
|
||||
|
||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
||||
expect(i18nService.t).toHaveBeenCalledWith("forwarderNoUrl", Forwarders.AddyIo.name);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"],
|
||||
["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"],
|
||||
["forwarderGeneratedBy", "not provided", null, ""],
|
||||
["forwarderGeneratedBy", "not provided", "", ""],
|
||||
])(
|
||||
"describes the website with %p when the website is %s (= %p)",
|
||||
async (translationKey, _ignored, website, expectedWebsite) => {
|
||||
const apiService = mockApiService(200, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await forwarder.generate({
|
||||
website,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["jane.doe@example.com", 201],
|
||||
["john.doe@example.com", 201],
|
||||
["jane.doe@example.com", 200],
|
||||
["john.doe@example.com", 200],
|
||||
])(
|
||||
"returns the generated email address (= %p) if the request is successful (status = %p)",
|
||||
async (email, status) => {
|
||||
const apiService = mockApiService(status, { data: { email } });
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
const result = await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
|
||||
expect(result).toEqual(email);
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
},
|
||||
);
|
||||
|
||||
it("throws an invalid token error if the request fails with a 401", async () => {
|
||||
const apiService = mockApiService(401, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
baseUrl: "https://api.example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwaderInvalidToken");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"forwaderInvalidToken",
|
||||
Forwarders.AddyIo.name,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws an unknown error if the request fails and no status is provided", async () => {
|
||||
const apiService = mockApiService(500, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
baseUrl: "https://api.example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwarderUnknownError");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"forwarderUnknownError",
|
||||
Forwarders.AddyIo.name,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[100, "Continue"],
|
||||
[202, "Accepted"],
|
||||
[300, "Multiple Choices"],
|
||||
[418, "I'm a teapot"],
|
||||
[500, "Internal Server Error"],
|
||||
[600, "Unknown Status"],
|
||||
])(
|
||||
"throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided",
|
||||
async (statusCode, statusText) => {
|
||||
const apiService = mockApiService(statusCode, {}, statusText);
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new AddyIoForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
baseUrl: "https://api.example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwarderError");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"forwarderError",
|
||||
Forwarders.AddyIo.name,
|
||||
statusText,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
100
libs/tools/generator/core/src/strategies/forwarders/addy-io.ts
Normal file
100
libs/tools/generator/core/src/strategies/forwarders/addy-io.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
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 { DefaultAddyIoOptions, Forwarders } from "../../data";
|
||||
import { EmailDomainOptions, SelfHostedApiOptions } from "../../types";
|
||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
||||
import { ADDY_IO_FORWARDER, ADDY_IO_BUFFER } from "../storage";
|
||||
|
||||
/** Generates a forwarding address for addy.io (formerly anon addy) */
|
||||
export class AddyIoForwarder extends ForwarderGeneratorStrategy<
|
||||
SelfHostedApiOptions & EmailDomainOptions
|
||||
> {
|
||||
/** Instantiates the forwarder
|
||||
* @param apiService used for ajax requests to the forwarding service
|
||||
* @param i18nService used to look up error strings
|
||||
* @param encryptService protects sensitive forwarder options
|
||||
* @param keyService looks up the user key when protecting data.
|
||||
* @param stateProvider creates the durable state for options storage
|
||||
*/
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private i18nService: I18nService,
|
||||
encryptService: EncryptService,
|
||||
keyService: CryptoService,
|
||||
stateProvider: StateProvider,
|
||||
) {
|
||||
super(encryptService, keyService, stateProvider, DefaultAddyIoOptions);
|
||||
}
|
||||
|
||||
// configuration
|
||||
readonly key = ADDY_IO_FORWARDER;
|
||||
readonly rolloverKey = ADDY_IO_BUFFER;
|
||||
|
||||
// request
|
||||
generate = async (options: SelfHostedApiOptions & EmailDomainOptions) => {
|
||||
if (!options.token || options.token === "") {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name);
|
||||
throw error;
|
||||
}
|
||||
if (!options.domain || options.domain === "") {
|
||||
const error = this.i18nService.t("forwarderNoDomain", Forwarders.AddyIo.name);
|
||||
throw error;
|
||||
}
|
||||
if (!options.baseUrl || options.baseUrl === "") {
|
||||
const error = this.i18nService.t("forwarderNoUrl", Forwarders.AddyIo.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let descriptionId = "forwarderGeneratedByWithWebsite";
|
||||
if (!options.website || options.website === "") {
|
||||
descriptionId = "forwarderGeneratedBy";
|
||||
}
|
||||
const description = this.i18nService.t(descriptionId, options.website ?? "");
|
||||
|
||||
const url = options.baseUrl + "/api/v1/aliases";
|
||||
const request = new Request(url, {
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
Authorization: "Bearer " + options.token,
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
domain: options.domain,
|
||||
description,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await this.apiService.nativeFetch(request);
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
const json = await response.json();
|
||||
return json?.data?.email;
|
||||
} else if (response.status === 401) {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.AddyIo.name);
|
||||
throw error;
|
||||
} else if (response?.statusText) {
|
||||
const error = this.i18nService.t(
|
||||
"forwarderError",
|
||||
Forwarders.AddyIo.name,
|
||||
response.statusText,
|
||||
);
|
||||
throw error;
|
||||
} else {
|
||||
const error = this.i18nService.t("forwarderUnknownError", Forwarders.AddyIo.name);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const DefaultOptions = Object.freeze({
|
||||
website: null,
|
||||
baseUrl: "https://app.addy.io",
|
||||
domain: "",
|
||||
token: "",
|
||||
});
|
@ -0,0 +1,144 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { Forwarders, DefaultDuckDuckGoOptions } from "../../data";
|
||||
import { DUCK_DUCK_GO_FORWARDER } from "../storage";
|
||||
|
||||
import { DuckDuckGoForwarder } from "./duck-duck-go";
|
||||
import { mockApiService, mockI18nService } from "./mocks.jest";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
|
||||
describe("DuckDuckGo Forwarder", () => {
|
||||
it("key returns the Duck Duck Go forwarder key", () => {
|
||||
const forwarder = new DuckDuckGoForwarder(null, null, null, null, null);
|
||||
|
||||
expect(forwarder.key).toBe(DUCK_DUCK_GO_FORWARDER);
|
||||
});
|
||||
|
||||
describe("defaults$", () => {
|
||||
it("should return the default subaddress options", async () => {
|
||||
const strategy = new DuckDuckGoForwarder(null, null, null, null, null);
|
||||
|
||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
||||
|
||||
expect(result).toEqual(DefaultDuckDuckGoOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
|
||||
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
|
||||
const apiService = mockApiService(200, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token,
|
||||
}),
|
||||
).rejects.toEqual("forwaderInvalidToken");
|
||||
|
||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"forwaderInvalidToken",
|
||||
Forwarders.DuckDuckGo.name,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["jane.doe@duck.com", 201, "jane.doe"],
|
||||
["john.doe@duck.com", 201, "john.doe"],
|
||||
["jane.doe@duck.com", 200, "jane.doe"],
|
||||
["john.doe@duck.com", 200, "john.doe"],
|
||||
])(
|
||||
"returns the generated email address (= %p) if the request is successful (status = %p)",
|
||||
async (email, status, address) => {
|
||||
const apiService = mockApiService(status, { address });
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
const result = await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
});
|
||||
|
||||
expect(result).toEqual(email);
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
},
|
||||
);
|
||||
|
||||
it("throws an invalid token error if the request fails with a 401", async () => {
|
||||
const apiService = mockApiService(401, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
}),
|
||||
).rejects.toEqual("forwaderInvalidToken");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"forwaderInvalidToken",
|
||||
Forwarders.DuckDuckGo.name,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws an unknown error if the request is successful but an address isn't present", async () => {
|
||||
const apiService = mockApiService(200, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
}),
|
||||
).rejects.toEqual("forwarderUnknownError");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"forwarderUnknownError",
|
||||
Forwarders.DuckDuckGo.name,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([100, 202, 300, 418, 500, 600])(
|
||||
"throws an unknown error if the request returns any other status code (= %i)",
|
||||
async (statusCode) => {
|
||||
const apiService = mockApiService(statusCode, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new DuckDuckGoForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
}),
|
||||
).rejects.toEqual("forwarderUnknownError");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"forwarderUnknownError",
|
||||
Forwarders.DuckDuckGo.name,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
@ -0,0 +1,75 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
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 { Forwarders, DefaultDuckDuckGoOptions } from "../../data";
|
||||
import { ApiOptions } from "../../types";
|
||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
||||
import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../storage";
|
||||
|
||||
/** Generates a forwarding address for DuckDuckGo */
|
||||
export class DuckDuckGoForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
|
||||
/** Instantiates the forwarder
|
||||
* @param apiService used for ajax requests to the forwarding service
|
||||
* @param i18nService used to look up error strings
|
||||
* @param encryptService protects sensitive forwarder options
|
||||
* @param keyService looks up the user key when protecting data.
|
||||
* @param stateProvider creates the durable state for options storage
|
||||
*/
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private i18nService: I18nService,
|
||||
encryptService: EncryptService,
|
||||
keyService: CryptoService,
|
||||
stateProvider: StateProvider,
|
||||
) {
|
||||
super(encryptService, keyService, stateProvider, DefaultDuckDuckGoOptions);
|
||||
}
|
||||
|
||||
// configuration
|
||||
readonly key = DUCK_DUCK_GO_FORWARDER;
|
||||
readonly rolloverKey = DUCK_DUCK_GO_BUFFER;
|
||||
|
||||
// request
|
||||
generate = async (options: ApiOptions): Promise<string> => {
|
||||
if (!options.token || options.token === "") {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const url = "https://quack.duckduckgo.com/api/email/addresses";
|
||||
const request = new Request(url, {
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
Authorization: "Bearer " + options.token,
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await this.apiService.nativeFetch(request);
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
const json = await response.json();
|
||||
if (json.address) {
|
||||
return `${json.address}@duck.com`;
|
||||
} else {
|
||||
const error = this.i18nService.t("forwarderUnknownError", Forwarders.DuckDuckGo.name);
|
||||
throw error;
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.DuckDuckGo.name);
|
||||
throw error;
|
||||
} else {
|
||||
const error = this.i18nService.t("forwarderUnknownError", Forwarders.DuckDuckGo.name);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const DefaultOptions = Object.freeze({
|
||||
website: null,
|
||||
token: "",
|
||||
});
|
@ -0,0 +1,281 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { Forwarders, DefaultFastmailOptions } from "../../data";
|
||||
import { FASTMAIL_FORWARDER } from "../storage";
|
||||
|
||||
import { FastmailForwarder } from "./fastmail";
|
||||
import { mockI18nService } from "./mocks.jest";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
|
||||
type MockResponse = { status: number; body: any };
|
||||
|
||||
// fastmail calls nativeFetch first to resolve the accountId,
|
||||
// then it calls nativeFetch again to create the forwarding address.
|
||||
// The common mock doesn't work here, because this test needs to return multiple responses
|
||||
function mockApiService(accountId: MockResponse, forwardingAddress: MockResponse) {
|
||||
function response(r: MockResponse) {
|
||||
return {
|
||||
status: r.status,
|
||||
json: jest.fn().mockImplementation(() => Promise.resolve(r.body)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nativeFetch: jest
|
||||
.fn()
|
||||
.mockImplementationOnce((r: Request) => response(accountId))
|
||||
.mockImplementationOnce((r: Request) => response(forwardingAddress)),
|
||||
} as unknown as ApiService;
|
||||
}
|
||||
|
||||
const EmptyResponse: MockResponse = Object.freeze({
|
||||
status: 200,
|
||||
body: Object.freeze({}),
|
||||
});
|
||||
|
||||
const AccountIdSuccess: MockResponse = Object.freeze({
|
||||
status: 200,
|
||||
body: Object.freeze({
|
||||
primaryAccounts: Object.freeze({
|
||||
"https://www.fastmail.com/dev/maskedemail": "accountId",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// the tests
|
||||
describe("Fastmail Forwarder", () => {
|
||||
it("key returns the Fastmail forwarder key", () => {
|
||||
const forwarder = new FastmailForwarder(null, null, null, null, null);
|
||||
|
||||
expect(forwarder.key).toBe(FASTMAIL_FORWARDER);
|
||||
});
|
||||
|
||||
describe("defaults$", () => {
|
||||
it("should return the default subaddress options", async () => {
|
||||
const strategy = new FastmailForwarder(null, null, null, null, null);
|
||||
|
||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
||||
|
||||
expect(result).toEqual(DefaultFastmailOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
|
||||
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
|
||||
const apiService = mockApiService(AccountIdSuccess, EmptyResponse);
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token,
|
||||
domain: "example.com",
|
||||
prefix: "prefix",
|
||||
}),
|
||||
).rejects.toEqual("forwaderInvalidToken");
|
||||
|
||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
||||
expect(i18nService.t).toHaveBeenCalledWith("forwaderInvalidToken", Forwarders.Fastmail.name);
|
||||
});
|
||||
|
||||
it.each([401, 403])(
|
||||
"throws a no account id error if the accountId request responds with a status other than 200",
|
||||
async (status) => {
|
||||
const apiService = mockApiService({ status, body: {} }, EmptyResponse);
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
prefix: "prefix",
|
||||
}),
|
||||
).rejects.toEqual("forwarderNoAccountId");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"forwarderNoAccountId",
|
||||
Forwarders.Fastmail.name,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["jane.doe@example.com", 200],
|
||||
["john.doe@example.com", 200],
|
||||
])(
|
||||
"returns the generated email address (= %p) if both requests are successful (status = %p)",
|
||||
async (email, status) => {
|
||||
const apiService = mockApiService(AccountIdSuccess, {
|
||||
status,
|
||||
body: {
|
||||
methodResponses: [["MaskedEmail/set", { created: { "new-masked-email": { email } } }]],
|
||||
},
|
||||
});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
const result = await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
prefix: "prefix",
|
||||
});
|
||||
|
||||
expect(result).toEqual(email);
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"It turned inside out!",
|
||||
[
|
||||
"MaskedEmail/set",
|
||||
{ notCreated: { "new-masked-email": { description: "It turned inside out!" } } },
|
||||
],
|
||||
],
|
||||
["And then it exploded!", ["error", { description: "And then it exploded!" }]],
|
||||
])(
|
||||
"throws a forwarder error (= %p) if both requests are successful (status = %p) but masked email creation fails",
|
||||
async (description, response) => {
|
||||
const apiService = mockApiService(AccountIdSuccess, {
|
||||
status: 200,
|
||||
body: {
|
||||
methodResponses: [response],
|
||||
},
|
||||
});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
prefix: "prefix",
|
||||
}),
|
||||
).rejects.toEqual("forwarderError");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"forwarderError",
|
||||
Forwarders.Fastmail.name,
|
||||
description,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([401, 403])(
|
||||
"throws an invalid token error if the jmap request fails with a %i",
|
||||
async (status) => {
|
||||
const apiService = mockApiService(AccountIdSuccess, { status, body: {} });
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
prefix: "prefix",
|
||||
}),
|
||||
).rejects.toEqual("forwaderInvalidToken");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"forwaderInvalidToken",
|
||||
Forwarders.Fastmail.name,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
null,
|
||||
[],
|
||||
[[]],
|
||||
[["MaskedEmail/not-a-real-op"]],
|
||||
[["MaskedEmail/set", null]],
|
||||
[["MaskedEmail/set", { created: null }]],
|
||||
[["MaskedEmail/set", { created: { "new-masked-email": null } }]],
|
||||
[["MaskedEmail/set", { notCreated: null }]],
|
||||
[["MaskedEmail/set", { notCreated: { "new-masked-email": null } }]],
|
||||
])(
|
||||
"throws an unknown error if the jmap request is malformed (= %p)",
|
||||
async (responses: any) => {
|
||||
const apiService = mockApiService(AccountIdSuccess, {
|
||||
status: 200,
|
||||
body: {
|
||||
methodResponses: responses,
|
||||
},
|
||||
});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
prefix: "prefix",
|
||||
}),
|
||||
).rejects.toEqual("forwarderUnknownError");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"forwarderUnknownError",
|
||||
Forwarders.Fastmail.name,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([100, 202, 300, 418, 500, 600])(
|
||||
"throws an unknown error if the request returns any other status code (= %i)",
|
||||
async (statusCode) => {
|
||||
const apiService = mockApiService(AccountIdSuccess, { status: statusCode, body: {} });
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FastmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
prefix: "prefix",
|
||||
}),
|
||||
).rejects.toEqual("forwarderUnknownError");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"forwarderUnknownError",
|
||||
Forwarders.Fastmail.name,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
150
libs/tools/generator/core/src/strategies/forwarders/fastmail.ts
Normal file
150
libs/tools/generator/core/src/strategies/forwarders/fastmail.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
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 { Forwarders, DefaultFastmailOptions } from "../../data";
|
||||
import { EmailPrefixOptions, ApiOptions } from "../../types";
|
||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
||||
import { FASTMAIL_FORWARDER, FASTMAIL_BUFFER } from "../storage";
|
||||
|
||||
/** Generates a forwarding address for Fastmail */
|
||||
export class FastmailForwarder extends ForwarderGeneratorStrategy<ApiOptions & EmailPrefixOptions> {
|
||||
/** Instantiates the forwarder
|
||||
* @param apiService used for ajax requests to the forwarding service
|
||||
* @param i18nService used to look up error strings
|
||||
* @param encryptService protects sensitive forwarder options
|
||||
* @param keyService looks up the user key when protecting data.
|
||||
* @param stateProvider creates the durable state for options storage
|
||||
*/
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private i18nService: I18nService,
|
||||
encryptService: EncryptService,
|
||||
keyService: CryptoService,
|
||||
stateProvider: StateProvider,
|
||||
) {
|
||||
super(encryptService, keyService, stateProvider, DefaultFastmailOptions);
|
||||
}
|
||||
|
||||
// configuration
|
||||
readonly key = FASTMAIL_FORWARDER;
|
||||
readonly rolloverKey = FASTMAIL_BUFFER;
|
||||
|
||||
// request
|
||||
generate = async (options: ApiOptions & EmailPrefixOptions) => {
|
||||
if (!options.token || options.token === "") {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const accountId = await this.getAccountId(options);
|
||||
if (!accountId || accountId === "") {
|
||||
const error = this.i18nService.t("forwarderNoAccountId", Forwarders.Fastmail.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const body = JSON.stringify({
|
||||
using: ["https://www.fastmail.com/dev/maskedemail", "urn:ietf:params:jmap:core"],
|
||||
methodCalls: [
|
||||
[
|
||||
"MaskedEmail/set",
|
||||
{
|
||||
accountId: accountId,
|
||||
create: {
|
||||
"new-masked-email": {
|
||||
state: "enabled",
|
||||
description: "",
|
||||
forDomain: options.website ?? "",
|
||||
emailPrefix: options.prefix,
|
||||
},
|
||||
},
|
||||
},
|
||||
"0",
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
const requestInit: RequestInit = {
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
Authorization: "Bearer " + options.token,
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body,
|
||||
};
|
||||
|
||||
const url = "https://api.fastmail.com/jmap/api/";
|
||||
const request = new Request(url, requestInit);
|
||||
|
||||
const response = await this.apiService.nativeFetch(request);
|
||||
if (response.status === 200) {
|
||||
const json = await response.json();
|
||||
if (
|
||||
json.methodResponses != null &&
|
||||
json.methodResponses.length > 0 &&
|
||||
json.methodResponses[0].length > 0
|
||||
) {
|
||||
if (json.methodResponses[0][0] === "MaskedEmail/set") {
|
||||
if (json.methodResponses[0][1]?.created?.["new-masked-email"] != null) {
|
||||
return json.methodResponses[0][1]?.created?.["new-masked-email"]?.email;
|
||||
}
|
||||
if (json.methodResponses[0][1]?.notCreated?.["new-masked-email"] != null) {
|
||||
const errorDescription =
|
||||
json.methodResponses[0][1]?.notCreated?.["new-masked-email"]?.description;
|
||||
const error = this.i18nService.t(
|
||||
"forwarderError",
|
||||
Forwarders.Fastmail.name,
|
||||
errorDescription,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
} else if (json.methodResponses[0][0] === "error") {
|
||||
const errorDescription = json.methodResponses[0][1]?.description;
|
||||
const error = this.i18nService.t(
|
||||
"forwarderError",
|
||||
Forwarders.Fastmail.name,
|
||||
errorDescription,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.Fastmail.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const error = this.i18nService.t("forwarderUnknownError", Forwarders.Fastmail.name);
|
||||
throw error;
|
||||
};
|
||||
|
||||
private async getAccountId(options: ApiOptions): Promise<string> {
|
||||
const requestInit: RequestInit = {
|
||||
cache: "no-store",
|
||||
method: "GET",
|
||||
headers: new Headers({
|
||||
Authorization: "Bearer " + options.token,
|
||||
}),
|
||||
};
|
||||
const url = "https://api.fastmail.com/.well-known/jmap";
|
||||
const request = new Request(url, requestInit);
|
||||
const response = await this.apiService.nativeFetch(request);
|
||||
if (response.status === 200) {
|
||||
const json = await response.json();
|
||||
if (json.primaryAccounts != null) {
|
||||
return json.primaryAccounts["https://www.fastmail.com/dev/maskedemail"];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const DefaultOptions = Object.freeze({
|
||||
website: null,
|
||||
domain: "",
|
||||
prefix: "",
|
||||
token: "",
|
||||
});
|
@ -0,0 +1,147 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { Forwarders, DefaultFirefoxRelayOptions } from "../../data";
|
||||
import { FIREFOX_RELAY_FORWARDER } from "../storage";
|
||||
|
||||
import { FirefoxRelayForwarder } from "./firefox-relay";
|
||||
import { mockApiService, mockI18nService } from "./mocks.jest";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
|
||||
describe("Firefox Relay Forwarder", () => {
|
||||
it("key returns the Firefox Relay forwarder key", () => {
|
||||
const forwarder = new FirefoxRelayForwarder(null, null, null, null, null);
|
||||
|
||||
expect(forwarder.key).toBe(FIREFOX_RELAY_FORWARDER);
|
||||
});
|
||||
|
||||
describe("defaults$", () => {
|
||||
it("should return the default subaddress options", async () => {
|
||||
const strategy = new FirefoxRelayForwarder(null, null, null, null, null);
|
||||
|
||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
||||
|
||||
expect(result).toEqual(DefaultFirefoxRelayOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
|
||||
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
|
||||
const apiService = mockApiService(200, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token,
|
||||
}),
|
||||
).rejects.toEqual("forwaderInvalidToken");
|
||||
|
||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"forwaderInvalidToken",
|
||||
Forwarders.FirefoxRelay.name,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"],
|
||||
["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"],
|
||||
["forwarderGeneratedBy", "not provided", null, ""],
|
||||
["forwarderGeneratedBy", "not provided", "", ""],
|
||||
])(
|
||||
"describes the website with %p when the website is %s (= %p)",
|
||||
async (translationKey, _ignored, website, expectedWebsite) => {
|
||||
const apiService = mockApiService(200, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await forwarder.generate({
|
||||
website,
|
||||
token: "token",
|
||||
});
|
||||
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["jane.doe@duck.com", 201],
|
||||
["john.doe@duck.com", 201],
|
||||
["jane.doe@duck.com", 200],
|
||||
["john.doe@duck.com", 200],
|
||||
])(
|
||||
"returns the generated email address (= %p) if the request is successful (status = %p)",
|
||||
async (full_address, status) => {
|
||||
const apiService = mockApiService(status, { full_address });
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
const result = await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
});
|
||||
|
||||
expect(result).toEqual(full_address);
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
},
|
||||
);
|
||||
|
||||
it("throws an invalid token error if the request fails with a 401", async () => {
|
||||
const apiService = mockApiService(401, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
}),
|
||||
).rejects.toEqual("forwaderInvalidToken");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"forwaderInvalidToken",
|
||||
Forwarders.FirefoxRelay.name,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([100, 202, 300, 418, 500, 600])(
|
||||
"throws an unknown error if the request returns any other status code (= %i)",
|
||||
async (statusCode) => {
|
||||
const apiService = mockApiService(statusCode, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new FirefoxRelayForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
}),
|
||||
).rejects.toEqual("forwarderUnknownError");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"forwarderUnknownError",
|
||||
Forwarders.FirefoxRelay.name,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
@ -0,0 +1,82 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
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 { Forwarders, DefaultFirefoxRelayOptions } from "../../data";
|
||||
import { ApiOptions } from "../../types";
|
||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
||||
import { FIREFOX_RELAY_FORWARDER, FIREFOX_RELAY_BUFFER } from "../storage";
|
||||
|
||||
/** Generates a forwarding address for Firefox Relay */
|
||||
export class FirefoxRelayForwarder extends ForwarderGeneratorStrategy<ApiOptions> {
|
||||
/** Instantiates the forwarder
|
||||
* @param apiService used for ajax requests to the forwarding service
|
||||
* @param i18nService used to look up error strings
|
||||
* @param encryptService protects sensitive forwarder options
|
||||
* @param keyService looks up the user key when protecting data.
|
||||
* @param stateProvider creates the durable state for options storage
|
||||
*/
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private i18nService: I18nService,
|
||||
encryptService: EncryptService,
|
||||
keyService: CryptoService,
|
||||
stateProvider: StateProvider,
|
||||
) {
|
||||
super(encryptService, keyService, stateProvider, DefaultFirefoxRelayOptions);
|
||||
}
|
||||
|
||||
// configuration
|
||||
readonly key = FIREFOX_RELAY_FORWARDER;
|
||||
readonly rolloverKey = FIREFOX_RELAY_BUFFER;
|
||||
|
||||
// request
|
||||
generate = async (options: ApiOptions) => {
|
||||
if (!options.token || options.token === "") {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const url = "https://relay.firefox.com/api/v1/relayaddresses/";
|
||||
|
||||
let descriptionId = "forwarderGeneratedByWithWebsite";
|
||||
if (!options.website || options.website === "") {
|
||||
descriptionId = "forwarderGeneratedBy";
|
||||
}
|
||||
const description = this.i18nService.t(descriptionId, options.website ?? "");
|
||||
|
||||
const request = new Request(url, {
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
Authorization: "Token " + options.token,
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
enabled: true,
|
||||
generated_for: options.website,
|
||||
description,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await this.apiService.nativeFetch(request);
|
||||
if (response.status === 401) {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.FirefoxRelay.name);
|
||||
throw error;
|
||||
} else if (response.status === 200 || response.status === 201) {
|
||||
const json = await response.json();
|
||||
return json.full_address;
|
||||
} else {
|
||||
const error = this.i18nService.t("forwarderUnknownError", Forwarders.FirefoxRelay.name);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const DefaultOptions = Object.freeze({
|
||||
website: null,
|
||||
token: "",
|
||||
});
|
@ -0,0 +1,277 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { Forwarders, DefaultForwardEmailOptions } from "../../data";
|
||||
import { FORWARD_EMAIL_FORWARDER } from "../storage";
|
||||
|
||||
import { ForwardEmailForwarder } from "./forward-email";
|
||||
import { mockApiService, mockI18nService } from "./mocks.jest";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
|
||||
describe("ForwardEmail Forwarder", () => {
|
||||
it("key returns the Forward Email forwarder key", () => {
|
||||
const forwarder = new ForwardEmailForwarder(null, null, null, null, null);
|
||||
|
||||
expect(forwarder.key).toBe(FORWARD_EMAIL_FORWARDER);
|
||||
});
|
||||
|
||||
describe("defaults$", () => {
|
||||
it("should return the default subaddress options", async () => {
|
||||
const strategy = new ForwardEmailForwarder(null, null, null, null, null);
|
||||
|
||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
||||
|
||||
expect(result).toEqual(DefaultForwardEmailOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
|
||||
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
|
||||
const apiService = mockApiService(200, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token,
|
||||
domain: "example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwaderInvalidToken");
|
||||
|
||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"forwaderInvalidToken",
|
||||
Forwarders.ForwardEmail.name,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([null, ""])(
|
||||
"throws an error if the domain is missing (domain = %p)",
|
||||
async (domain) => {
|
||||
const apiService = mockApiService(200, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain,
|
||||
}),
|
||||
).rejects.toEqual("forwarderNoDomain");
|
||||
|
||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"forwarderNoDomain",
|
||||
Forwarders.ForwardEmail.name,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"],
|
||||
["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"],
|
||||
["forwarderGeneratedBy", "not provided", null, ""],
|
||||
["forwarderGeneratedBy", "not provided", "", ""],
|
||||
])(
|
||||
"describes the website with %p when the website is %s (= %p)",
|
||||
async (translationKey, _ignored, website, expectedWebsite) => {
|
||||
const apiService = mockApiService(200, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await forwarder.generate({
|
||||
website,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
});
|
||||
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["jane.doe@example.com", 201, { name: "jane.doe", domain: { name: "example.com" } }],
|
||||
["jane.doe@example.com", 201, { name: "jane.doe" }],
|
||||
["john.doe@example.com", 201, { name: "john.doe", domain: { name: "example.com" } }],
|
||||
["john.doe@example.com", 201, { name: "john.doe" }],
|
||||
["jane.doe@example.com", 200, { name: "jane.doe", domain: { name: "example.com" } }],
|
||||
["jane.doe@example.com", 200, { name: "jane.doe" }],
|
||||
["john.doe@example.com", 200, { name: "john.doe", domain: { name: "example.com" } }],
|
||||
["john.doe@example.com", 200, { name: "john.doe" }],
|
||||
])(
|
||||
"returns the generated email address (= %p) if the request is successful (status = %p)",
|
||||
async (email, status, response) => {
|
||||
const apiService = mockApiService(status, response);
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
const result = await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
});
|
||||
|
||||
expect(result).toEqual(email);
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
},
|
||||
);
|
||||
|
||||
it("throws an invalid token error if the request fails with a 401", async () => {
|
||||
const apiService = mockApiService(401, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwaderInvalidToken");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"forwaderInvalidToken",
|
||||
Forwarders.ForwardEmail.name,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws an invalid token error with a message if the request fails with a 401 and message", async () => {
|
||||
const apiService = mockApiService(401, { message: "A message" });
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwaderInvalidTokenWithMessage");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"forwaderInvalidTokenWithMessage",
|
||||
Forwarders.ForwardEmail.name,
|
||||
"A message",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([{}, null])(
|
||||
"throws an unknown error if the request fails and no status (= %p) is provided",
|
||||
async (json) => {
|
||||
const apiService = mockApiService(500, json);
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwarderUnknownError");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"forwarderUnknownError",
|
||||
Forwarders.ForwardEmail.name,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[100, "Continue"],
|
||||
[202, "Accepted"],
|
||||
[300, "Multiple Choices"],
|
||||
[418, "I'm a teapot"],
|
||||
[500, "Internal Server Error"],
|
||||
[600, "Unknown Status"],
|
||||
])(
|
||||
"throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided",
|
||||
async (statusCode, message) => {
|
||||
const apiService = mockApiService(statusCode, { message });
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwarderError");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"forwarderError",
|
||||
Forwarders.ForwardEmail.name,
|
||||
message,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[100, "Continue"],
|
||||
[202, "Accepted"],
|
||||
[300, "Multiple Choices"],
|
||||
[418, "I'm a teapot"],
|
||||
[500, "Internal Server Error"],
|
||||
[600, "Unknown Status"],
|
||||
])(
|
||||
"throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided",
|
||||
async (statusCode, error) => {
|
||||
const apiService = mockApiService(statusCode, { error });
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new ForwardEmailForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
domain: "example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwarderError");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"forwarderError",
|
||||
Forwarders.ForwardEmail.name,
|
||||
error,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
@ -0,0 +1,104 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
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 { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { Forwarders, DefaultForwardEmailOptions } from "../../data";
|
||||
import { EmailDomainOptions, ApiOptions } from "../../types";
|
||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
||||
import { FORWARD_EMAIL_FORWARDER, FORWARD_EMAIL_BUFFER } from "../storage";
|
||||
|
||||
/** Generates a forwarding address for Forward Email */
|
||||
export class ForwardEmailForwarder extends ForwarderGeneratorStrategy<
|
||||
ApiOptions & EmailDomainOptions
|
||||
> {
|
||||
/** Instantiates the forwarder
|
||||
* @param apiService used for ajax requests to the forwarding service
|
||||
* @param i18nService used to look up error strings
|
||||
* @param encryptService protects sensitive forwarder options
|
||||
* @param keyService looks up the user key when protecting data.
|
||||
* @param stateProvider creates the durable state for options storage
|
||||
*/
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private i18nService: I18nService,
|
||||
encryptService: EncryptService,
|
||||
keyService: CryptoService,
|
||||
stateProvider: StateProvider,
|
||||
) {
|
||||
super(encryptService, keyService, stateProvider, DefaultForwardEmailOptions);
|
||||
}
|
||||
|
||||
// configuration
|
||||
readonly key = FORWARD_EMAIL_FORWARDER;
|
||||
readonly rolloverKey = FORWARD_EMAIL_BUFFER;
|
||||
|
||||
// request
|
||||
generate = async (options: ApiOptions & EmailDomainOptions) => {
|
||||
if (!options.token || options.token === "") {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.ForwardEmail.name);
|
||||
throw error;
|
||||
}
|
||||
if (!options.domain || options.domain === "") {
|
||||
const error = this.i18nService.t("forwarderNoDomain", Forwarders.ForwardEmail.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const url = `https://api.forwardemail.net/v1/domains/${options.domain}/aliases`;
|
||||
|
||||
let descriptionId = "forwarderGeneratedByWithWebsite";
|
||||
if (!options.website || options.website === "") {
|
||||
descriptionId = "forwarderGeneratedBy";
|
||||
}
|
||||
const description = this.i18nService.t(descriptionId, options.website ?? "");
|
||||
|
||||
const request = new Request(url, {
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
Authorization: "Basic " + Utils.fromUtf8ToB64(options.token + ":"),
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
labels: options.website,
|
||||
description,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await this.apiService.nativeFetch(request);
|
||||
const json = await response.json();
|
||||
|
||||
if (response.status === 401) {
|
||||
const messageKey =
|
||||
"message" in json ? "forwaderInvalidTokenWithMessage" : "forwaderInvalidToken";
|
||||
const error = this.i18nService.t(messageKey, Forwarders.ForwardEmail.name, json.message);
|
||||
throw error;
|
||||
} else if (response.status === 200 || response.status === 201) {
|
||||
const { name, domain } = await response.json();
|
||||
const domainPart = domain?.name || options.domain;
|
||||
return `${name}@${domainPart}`;
|
||||
} else if (json?.message) {
|
||||
const error = this.i18nService.t(
|
||||
"forwarderError",
|
||||
Forwarders.ForwardEmail.name,
|
||||
json.message,
|
||||
);
|
||||
throw error;
|
||||
} else if (json?.error) {
|
||||
const error = this.i18nService.t("forwarderError", Forwarders.ForwardEmail.name, json.error);
|
||||
throw error;
|
||||
} else {
|
||||
const error = this.i18nService.t("forwarderUnknownError", Forwarders.ForwardEmail.name);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const DefaultOptions = Object.freeze({
|
||||
website: null,
|
||||
token: "",
|
||||
domain: "",
|
||||
});
|
@ -0,0 +1,22 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
/** a mock {@link ApiService} that returns a fetch-like response with a given status and body */
|
||||
export function mockApiService(status: number, body: any, statusText?: string) {
|
||||
return {
|
||||
nativeFetch: jest.fn().mockImplementation((r: Request) => {
|
||||
return {
|
||||
status,
|
||||
statusText,
|
||||
json: jest.fn().mockImplementation(() => Promise.resolve(body)),
|
||||
};
|
||||
}),
|
||||
} as unknown as ApiService;
|
||||
}
|
||||
|
||||
/** a mock {@link I18nService} that returns the translation key */
|
||||
export function mockI18nService() {
|
||||
return {
|
||||
t: jest.fn().mockImplementation((key: string) => key),
|
||||
} as unknown as I18nService;
|
||||
}
|
@ -0,0 +1,209 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { Forwarders, DefaultSimpleLoginOptions } from "../../data";
|
||||
import { SIMPLE_LOGIN_FORWARDER } from "../storage";
|
||||
|
||||
import { mockApiService, mockI18nService } from "./mocks.jest";
|
||||
import { SimpleLoginForwarder } from "./simple-login";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
|
||||
describe("SimpleLogin Forwarder", () => {
|
||||
it("key returns the Simple Login forwarder key", () => {
|
||||
const forwarder = new SimpleLoginForwarder(null, null, null, null, null);
|
||||
|
||||
expect(forwarder.key).toBe(SIMPLE_LOGIN_FORWARDER);
|
||||
});
|
||||
|
||||
describe("defaults$", () => {
|
||||
it("should return the default subaddress options", async () => {
|
||||
const strategy = new SimpleLoginForwarder(null, null, null, null, null);
|
||||
|
||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
||||
|
||||
expect(result).toEqual(DefaultSimpleLoginOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate(string | null, SelfHostedApiOptions & EmailDomainOptions)", () => {
|
||||
it.each([null, ""])("throws an error if the token is missing (token = %p)", async (token) => {
|
||||
const apiService = mockApiService(200, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token,
|
||||
baseUrl: "https://api.example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwaderInvalidToken");
|
||||
|
||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
||||
expect(i18nService.t).toHaveBeenCalledWith(
|
||||
"forwaderInvalidToken",
|
||||
Forwarders.SimpleLogin.name,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([null, ""])(
|
||||
"throws an error if the baseUrl is missing (baseUrl = %p)",
|
||||
async (baseUrl) => {
|
||||
const apiService = mockApiService(200, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
baseUrl,
|
||||
}),
|
||||
).rejects.toEqual("forwarderNoUrl");
|
||||
|
||||
expect(apiService.nativeFetch).not.toHaveBeenCalled();
|
||||
expect(i18nService.t).toHaveBeenCalledWith("forwarderNoUrl", Forwarders.SimpleLogin.name);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["forwarderGeneratedByWithWebsite", "provided", "bitwarden.com", "bitwarden.com"],
|
||||
["forwarderGeneratedByWithWebsite", "provided", "httpbin.org", "httpbin.org"],
|
||||
["forwarderGeneratedBy", "not provided", null, ""],
|
||||
["forwarderGeneratedBy", "not provided", "", ""],
|
||||
])(
|
||||
"describes the website with %p when the website is %s (= %p)",
|
||||
async (translationKey, _ignored, website, expectedWebsite) => {
|
||||
const apiService = mockApiService(200, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await forwarder.generate({
|
||||
website,
|
||||
token: "token",
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenCalledWith(translationKey, expectedWebsite);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["jane.doe@example.com", 201],
|
||||
["john.doe@example.com", 201],
|
||||
["jane.doe@example.com", 200],
|
||||
["john.doe@example.com", 200],
|
||||
])(
|
||||
"returns the generated email address (= %p) if the request is successful (status = %p)",
|
||||
async (alias, status) => {
|
||||
const apiService = mockApiService(status, { alias });
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
const result = await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
baseUrl: "https://api.example.com",
|
||||
});
|
||||
|
||||
expect(result).toEqual(alias);
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
},
|
||||
);
|
||||
|
||||
it("throws an invalid token error if the request fails with a 401", async () => {
|
||||
const apiService = mockApiService(401, {});
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
baseUrl: "https://api.example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwaderInvalidToken");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"forwaderInvalidToken",
|
||||
Forwarders.SimpleLogin.name,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([{}, null])(
|
||||
"throws an unknown error if the request fails and no status (=%p) is provided",
|
||||
async (body) => {
|
||||
const apiService = mockApiService(500, body);
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
baseUrl: "https://api.example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwarderUnknownError");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"forwarderUnknownError",
|
||||
Forwarders.SimpleLogin.name,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[100, "Continue"],
|
||||
[202, "Accepted"],
|
||||
[300, "Multiple Choices"],
|
||||
[418, "I'm a teapot"],
|
||||
[500, "Internal Server Error"],
|
||||
[600, "Unknown Status"],
|
||||
])(
|
||||
"throws an error with the status text if the request returns any other status code (= %i) and a status (= %p) is provided",
|
||||
async (statusCode, error) => {
|
||||
const apiService = mockApiService(statusCode, { error });
|
||||
const i18nService = mockI18nService();
|
||||
|
||||
const forwarder = new SimpleLoginForwarder(apiService, i18nService, null, null, null);
|
||||
|
||||
await expect(
|
||||
async () =>
|
||||
await forwarder.generate({
|
||||
website: null,
|
||||
token: "token",
|
||||
baseUrl: "https://api.example.com",
|
||||
}),
|
||||
).rejects.toEqual("forwarderError");
|
||||
|
||||
expect(apiService.nativeFetch).toHaveBeenCalledWith(expect.any(Request));
|
||||
// counting instances is terribly flaky over changes, but jest doesn't have a better way to do this
|
||||
expect(i18nService.t).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"forwarderError",
|
||||
Forwarders.SimpleLogin.name,
|
||||
error,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
@ -0,0 +1,88 @@
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
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 { Forwarders, DefaultSimpleLoginOptions } from "../../data";
|
||||
import { SelfHostedApiOptions } from "../../types";
|
||||
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
|
||||
import { SIMPLE_LOGIN_FORWARDER, SIMPLE_LOGIN_BUFFER } from "../storage";
|
||||
|
||||
/** Generates a forwarding address for Simple Login */
|
||||
export class SimpleLoginForwarder extends ForwarderGeneratorStrategy<SelfHostedApiOptions> {
|
||||
/** Instantiates the forwarder
|
||||
* @param apiService used for ajax requests to the forwarding service
|
||||
* @param i18nService used to look up error strings
|
||||
* @param encryptService protects sensitive forwarder options
|
||||
* @param keyService looks up the user key when protecting data.
|
||||
* @param stateProvider creates the durable state for options storage
|
||||
*/
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private i18nService: I18nService,
|
||||
encryptService: EncryptService,
|
||||
keyService: CryptoService,
|
||||
stateProvider: StateProvider,
|
||||
) {
|
||||
super(encryptService, keyService, stateProvider, DefaultSimpleLoginOptions);
|
||||
}
|
||||
|
||||
// configuration
|
||||
readonly key = SIMPLE_LOGIN_FORWARDER;
|
||||
readonly rolloverKey = SIMPLE_LOGIN_BUFFER;
|
||||
|
||||
// request
|
||||
generate = async (options: SelfHostedApiOptions) => {
|
||||
if (!options.token || options.token === "") {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name);
|
||||
throw error;
|
||||
}
|
||||
if (!options.baseUrl || options.baseUrl === "") {
|
||||
const error = this.i18nService.t("forwarderNoUrl", Forwarders.SimpleLogin.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let url = options.baseUrl + "/api/alias/random/new";
|
||||
let noteId = "forwarderGeneratedBy";
|
||||
if (options.website && options.website !== "") {
|
||||
url += "?hostname=" + options.website;
|
||||
noteId = "forwarderGeneratedByWithWebsite";
|
||||
}
|
||||
const note = this.i18nService.t(noteId, options.website ?? "");
|
||||
|
||||
const request = new Request(url, {
|
||||
redirect: "manual",
|
||||
cache: "no-store",
|
||||
method: "POST",
|
||||
headers: new Headers({
|
||||
Authentication: options.token,
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
|
||||
const response = await this.apiService.nativeFetch(request);
|
||||
if (response.status === 401) {
|
||||
const error = this.i18nService.t("forwaderInvalidToken", Forwarders.SimpleLogin.name);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
return json.alias;
|
||||
} else if (json?.error) {
|
||||
const error = this.i18nService.t("forwarderError", Forwarders.SimpleLogin.name, json.error);
|
||||
throw error;
|
||||
} else {
|
||||
const error = this.i18nService.t("forwarderUnknownError", Forwarders.SimpleLogin.name);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const DefaultOptions = Object.freeze({
|
||||
website: null,
|
||||
baseUrl: "https://app.simplelogin.io",
|
||||
token: "",
|
||||
});
|
11
libs/tools/generator/core/src/strategies/index.ts
Normal file
11
libs/tools/generator/core/src/strategies/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export { PassphraseGeneratorStrategy } from "./passphrase-generator-strategy";
|
||||
export { PasswordGeneratorStrategy } from "./password-generator-strategy";
|
||||
export { CatchallGeneratorStrategy } from "./catchall-generator-strategy";
|
||||
export { SubaddressGeneratorStrategy } from "./subaddress-generator-strategy";
|
||||
export { EffUsernameGeneratorStrategy } from "./eff-username-generator-strategy";
|
||||
export { AddyIoForwarder } from "./forwarders/addy-io";
|
||||
export { DuckDuckGoForwarder } from "./forwarders/duck-duck-go";
|
||||
export { FastmailForwarder } from "./forwarders/fastmail";
|
||||
export { FirefoxRelayForwarder } from "./forwarders/firefox-relay";
|
||||
export { ForwardEmailForwarder } from "./forwarders/forward-email";
|
||||
export { SimpleLoginForwarder } from "./forwarders/simple-login";
|
@ -0,0 +1,92 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of, firstValueFrom } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { Randomizer } from "../abstractions";
|
||||
import { DefaultPassphraseGenerationOptions, DisabledPassphraseGeneratorPolicy } from "../data";
|
||||
import { PassphraseGeneratorOptionsEvaluator } from "../policies";
|
||||
|
||||
import { PassphraseGeneratorStrategy } from "./passphrase-generator-strategy";
|
||||
import { PASSPHRASE_SETTINGS } from "./storage";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
|
||||
describe("Password generation strategy", () => {
|
||||
describe("toEvaluator()", () => {
|
||||
it("should map to the policy evaluator", async () => {
|
||||
const strategy = new PassphraseGeneratorStrategy(null, null);
|
||||
const policy = mock<Policy>({
|
||||
type: PolicyType.PasswordGenerator,
|
||||
data: {
|
||||
minNumberWords: 10,
|
||||
capitalize: true,
|
||||
includeNumber: true,
|
||||
},
|
||||
});
|
||||
|
||||
const evaluator$ = of([policy]).pipe(strategy.toEvaluator());
|
||||
const evaluator = await firstValueFrom(evaluator$);
|
||||
|
||||
expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator);
|
||||
expect(evaluator.policy).toMatchObject({
|
||||
minNumberWords: 10,
|
||||
capitalize: true,
|
||||
includeNumber: true,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([[[]], [null], [undefined]])(
|
||||
"should map `%p` to a disabled password policy evaluator",
|
||||
async (policies) => {
|
||||
const strategy = new PassphraseGeneratorStrategy(null, null);
|
||||
|
||||
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
|
||||
const evaluator = await firstValueFrom(evaluator$);
|
||||
|
||||
expect(evaluator).toBeInstanceOf(PassphraseGeneratorOptionsEvaluator);
|
||||
expect(evaluator.policy).toMatchObject(DisabledPassphraseGeneratorPolicy);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("durableState", () => {
|
||||
it("should use password settings key", () => {
|
||||
const provider = mock<StateProvider>();
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new PassphraseGeneratorStrategy(randomizer, provider);
|
||||
|
||||
strategy.durableState(SomeUser);
|
||||
|
||||
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, PASSPHRASE_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaults$", () => {
|
||||
it("should return the default subaddress options", async () => {
|
||||
const strategy = new PassphraseGeneratorStrategy(null, null);
|
||||
|
||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
||||
|
||||
expect(result).toEqual(DefaultPassphraseGenerationOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy", () => {
|
||||
it("should use password generator policy", () => {
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new PassphraseGeneratorStrategy(randomizer, null);
|
||||
|
||||
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate()", () => {
|
||||
it.todo("should generate a password using the given options");
|
||||
});
|
||||
});
|
@ -0,0 +1,66 @@
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { GeneratorStrategy, Randomizer } from "../abstractions";
|
||||
import { DefaultPassphraseGenerationOptions, Policies } from "../data";
|
||||
import { mapPolicyToEvaluator } from "../rx";
|
||||
import { PassphraseGenerationOptions, PassphraseGeneratorPolicy } from "../types";
|
||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
||||
|
||||
import { PASSPHRASE_SETTINGS } from "./storage";
|
||||
|
||||
/** Generates passphrases composed of random words */
|
||||
export class PassphraseGeneratorStrategy
|
||||
implements GeneratorStrategy<PassphraseGenerationOptions, PassphraseGeneratorPolicy>
|
||||
{
|
||||
/** instantiates the password generator strategy.
|
||||
* @param legacy generates the passphrase
|
||||
* @param stateProvider provides durable state
|
||||
*/
|
||||
constructor(
|
||||
private randomizer: Randomizer,
|
||||
private stateProvider: StateProvider,
|
||||
) {}
|
||||
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(PASSPHRASE_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(DefaultPassphraseGenerationOptions);
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
toEvaluator() {
|
||||
return mapPolicyToEvaluator(Policies.Passphrase);
|
||||
}
|
||||
|
||||
// algorithm
|
||||
async generate(options: PassphraseGenerationOptions): Promise<string> {
|
||||
const o = { ...DefaultPassphraseGenerationOptions, ...options };
|
||||
if (o.numWords == null || o.numWords <= 2) {
|
||||
o.numWords = DefaultPassphraseGenerationOptions.numWords;
|
||||
}
|
||||
if (o.capitalize == null) {
|
||||
o.capitalize = false;
|
||||
}
|
||||
if (o.includeNumber == null) {
|
||||
o.includeNumber = false;
|
||||
}
|
||||
|
||||
// select which word gets the number, if any
|
||||
let luckyNumber = -1;
|
||||
if (o.includeNumber) {
|
||||
luckyNumber = await this.randomizer.uniform(0, o.numWords);
|
||||
}
|
||||
|
||||
// generate the passphrase
|
||||
const wordList = new Array(o.numWords);
|
||||
for (let i = 0; i < o.numWords; i++) {
|
||||
const word = await this.randomizer.pickWord(EFFLongWordList, {
|
||||
titleCase: o.capitalize,
|
||||
number: i === luckyNumber,
|
||||
});
|
||||
|
||||
wordList[i] = word;
|
||||
}
|
||||
|
||||
return wordList.join(o.wordSeparator);
|
||||
}
|
||||
}
|
@ -0,0 +1,100 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of, firstValueFrom } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { Randomizer } from "../abstractions";
|
||||
import { DefaultPasswordGenerationOptions, DisabledPasswordGeneratorPolicy } from "../data";
|
||||
import { PasswordGeneratorOptionsEvaluator } from "../policies";
|
||||
|
||||
import { PasswordGeneratorStrategy } from "./password-generator-strategy";
|
||||
import { PASSWORD_SETTINGS } from "./storage";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
|
||||
describe("Password generation strategy", () => {
|
||||
describe("toEvaluator()", () => {
|
||||
it("should map to a password policy evaluator", async () => {
|
||||
const strategy = new PasswordGeneratorStrategy(null, null);
|
||||
const policy = mock<Policy>({
|
||||
type: PolicyType.PasswordGenerator,
|
||||
data: {
|
||||
minLength: 10,
|
||||
useUpper: true,
|
||||
useLower: true,
|
||||
useNumbers: true,
|
||||
minNumbers: 1,
|
||||
useSpecial: true,
|
||||
minSpecial: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const evaluator$ = of([policy]).pipe(strategy.toEvaluator());
|
||||
const evaluator = await firstValueFrom(evaluator$);
|
||||
|
||||
expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator);
|
||||
expect(evaluator.policy).toMatchObject({
|
||||
minLength: 10,
|
||||
useUppercase: true,
|
||||
useLowercase: true,
|
||||
useNumbers: true,
|
||||
numberCount: 1,
|
||||
useSpecial: true,
|
||||
specialCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([[[]], [null], [undefined]])(
|
||||
"should map `%p` to a disabled password policy evaluator",
|
||||
async (policies) => {
|
||||
const strategy = new PasswordGeneratorStrategy(null, null);
|
||||
|
||||
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
|
||||
const evaluator = await firstValueFrom(evaluator$);
|
||||
|
||||
expect(evaluator).toBeInstanceOf(PasswordGeneratorOptionsEvaluator);
|
||||
expect(evaluator.policy).toMatchObject(DisabledPasswordGeneratorPolicy);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("durableState", () => {
|
||||
it("should use password settings key", () => {
|
||||
const provider = mock<StateProvider>();
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new PasswordGeneratorStrategy(randomizer, provider);
|
||||
|
||||
strategy.durableState(SomeUser);
|
||||
|
||||
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, PASSWORD_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaults$", () => {
|
||||
it("should return the default subaddress options", async () => {
|
||||
const strategy = new PasswordGeneratorStrategy(null, null);
|
||||
|
||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
||||
|
||||
expect(result).toEqual(DefaultPasswordGenerationOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy", () => {
|
||||
it("should use password generator policy", () => {
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new PasswordGeneratorStrategy(randomizer, null);
|
||||
|
||||
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate()", () => {
|
||||
it.todo("should generate a password using the given options");
|
||||
});
|
||||
});
|
@ -0,0 +1,124 @@
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { GeneratorStrategy, Randomizer } from "../abstractions";
|
||||
import { Policies, DefaultPasswordGenerationOptions } from "../data";
|
||||
import { mapPolicyToEvaluator } from "../rx";
|
||||
import { PasswordGenerationOptions, PasswordGeneratorPolicy } from "../types";
|
||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
||||
|
||||
import { PASSWORD_SETTINGS } from "./storage";
|
||||
|
||||
/** Generates passwords composed of random characters */
|
||||
export class PasswordGeneratorStrategy
|
||||
implements GeneratorStrategy<PasswordGenerationOptions, PasswordGeneratorPolicy>
|
||||
{
|
||||
/** instantiates the password generator strategy.
|
||||
* @param legacy generates the password
|
||||
*/
|
||||
constructor(
|
||||
private randomizer: Randomizer,
|
||||
private stateProvider: StateProvider,
|
||||
) {}
|
||||
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(PASSWORD_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(DefaultPasswordGenerationOptions);
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
toEvaluator() {
|
||||
return mapPolicyToEvaluator(Policies.Password);
|
||||
}
|
||||
|
||||
// algorithm
|
||||
async generate(options: PasswordGenerationOptions): Promise<string> {
|
||||
const o = { ...DefaultPasswordGenerationOptions, ...options };
|
||||
let positions: string[] = [];
|
||||
if (o.lowercase && o.minLowercase > 0) {
|
||||
for (let i = 0; i < o.minLowercase; i++) {
|
||||
positions.push("l");
|
||||
}
|
||||
}
|
||||
if (o.uppercase && o.minUppercase > 0) {
|
||||
for (let i = 0; i < o.minUppercase; i++) {
|
||||
positions.push("u");
|
||||
}
|
||||
}
|
||||
if (o.number && o.minNumber > 0) {
|
||||
for (let i = 0; i < o.minNumber; i++) {
|
||||
positions.push("n");
|
||||
}
|
||||
}
|
||||
if (o.special && o.minSpecial > 0) {
|
||||
for (let i = 0; i < o.minSpecial; i++) {
|
||||
positions.push("s");
|
||||
}
|
||||
}
|
||||
while (positions.length < o.length) {
|
||||
positions.push("a");
|
||||
}
|
||||
|
||||
// shuffle
|
||||
positions = await this.randomizer.shuffle(positions);
|
||||
|
||||
// build out the char sets
|
||||
let allCharSet = "";
|
||||
|
||||
let lowercaseCharSet = "abcdefghijkmnopqrstuvwxyz";
|
||||
if (o.ambiguous) {
|
||||
lowercaseCharSet += "l";
|
||||
}
|
||||
if (o.lowercase) {
|
||||
allCharSet += lowercaseCharSet;
|
||||
}
|
||||
|
||||
let uppercaseCharSet = "ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||||
if (o.ambiguous) {
|
||||
uppercaseCharSet += "IO";
|
||||
}
|
||||
if (o.uppercase) {
|
||||
allCharSet += uppercaseCharSet;
|
||||
}
|
||||
|
||||
let numberCharSet = "23456789";
|
||||
if (o.ambiguous) {
|
||||
numberCharSet += "01";
|
||||
}
|
||||
if (o.number) {
|
||||
allCharSet += numberCharSet;
|
||||
}
|
||||
|
||||
const specialCharSet = "!@#$%^&*";
|
||||
if (o.special) {
|
||||
allCharSet += specialCharSet;
|
||||
}
|
||||
|
||||
let password = "";
|
||||
for (let i = 0; i < o.length; i++) {
|
||||
let positionChars: string;
|
||||
switch (positions[i]) {
|
||||
case "l":
|
||||
positionChars = lowercaseCharSet;
|
||||
break;
|
||||
case "u":
|
||||
positionChars = uppercaseCharSet;
|
||||
break;
|
||||
case "n":
|
||||
positionChars = numberCharSet;
|
||||
break;
|
||||
case "s":
|
||||
positionChars = specialCharSet;
|
||||
break;
|
||||
case "a":
|
||||
positionChars = allCharSet;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
const randomCharIndex = await this.randomizer.uniform(0, positionChars.length - 1);
|
||||
password += positionChars.charAt(randomCharIndex);
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
}
|
169
libs/tools/generator/core/src/strategies/storage.spec.ts
Normal file
169
libs/tools/generator/core/src/strategies/storage.spec.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import {
|
||||
EFF_USERNAME_SETTINGS,
|
||||
CATCHALL_SETTINGS,
|
||||
SUBADDRESS_SETTINGS,
|
||||
PASSPHRASE_SETTINGS,
|
||||
PASSWORD_SETTINGS,
|
||||
SIMPLE_LOGIN_FORWARDER,
|
||||
FORWARD_EMAIL_FORWARDER,
|
||||
FIREFOX_RELAY_FORWARDER,
|
||||
FASTMAIL_FORWARDER,
|
||||
DUCK_DUCK_GO_FORWARDER,
|
||||
ADDY_IO_FORWARDER,
|
||||
ADDY_IO_BUFFER,
|
||||
DUCK_DUCK_GO_BUFFER,
|
||||
FASTMAIL_BUFFER,
|
||||
FIREFOX_RELAY_BUFFER,
|
||||
FORWARD_EMAIL_BUFFER,
|
||||
SIMPLE_LOGIN_BUFFER,
|
||||
} from "./storage";
|
||||
|
||||
describe("Key definitions", () => {
|
||||
describe("PASSWORD_SETTINGS", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value = {};
|
||||
const result = PASSWORD_SETTINGS.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PASSPHRASE_SETTINGS", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value = {};
|
||||
const result = PASSPHRASE_SETTINGS.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EFF_USERNAME_SETTINGS", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value = { website: null as string };
|
||||
const result = EFF_USERNAME_SETTINGS.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CATCHALL_SETTINGS", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value = { website: null as string };
|
||||
const result = CATCHALL_SETTINGS.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SUBADDRESS_SETTINGS", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value = { website: null as string };
|
||||
const result = SUBADDRESS_SETTINGS.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ADDY_IO_FORWARDER", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value: any = {};
|
||||
const result = ADDY_IO_FORWARDER.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DUCK_DUCK_GO_FORWARDER", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value: any = {};
|
||||
const result = DUCK_DUCK_GO_FORWARDER.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FASTMAIL_FORWARDER", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value: any = {};
|
||||
const result = FASTMAIL_FORWARDER.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FIREFOX_RELAY_FORWARDER", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value: any = {};
|
||||
const result = FIREFOX_RELAY_FORWARDER.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FORWARD_EMAIL_FORWARDER", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value: any = {};
|
||||
const result = FORWARD_EMAIL_FORWARDER.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SIMPLE_LOGIN_FORWARDER", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value: any = {};
|
||||
const result = SIMPLE_LOGIN_FORWARDER.deserializer(value);
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ADDY_IO_BUFFER", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value: any = {};
|
||||
|
||||
const result = ADDY_IO_BUFFER.options.deserializer(value);
|
||||
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DUCK_DUCK_GO_BUFFER", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value: any = {};
|
||||
|
||||
const result = DUCK_DUCK_GO_BUFFER.options.deserializer(value);
|
||||
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FASTMAIL_BUFFER", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value: any = {};
|
||||
|
||||
const result = FASTMAIL_BUFFER.options.deserializer(value);
|
||||
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FIREFOX_RELAY_BUFFER", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value: any = {};
|
||||
|
||||
const result = FIREFOX_RELAY_BUFFER.options.deserializer(value);
|
||||
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FORWARD_EMAIL_BUFFER", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value: any = {};
|
||||
|
||||
const result = FORWARD_EMAIL_BUFFER.options.deserializer(value);
|
||||
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SIMPLE_LOGIN_BUFFER", () => {
|
||||
it("should pass through deserialization", () => {
|
||||
const value: any = {};
|
||||
|
||||
const result = SIMPLE_LOGIN_BUFFER.options.deserializer(value);
|
||||
|
||||
expect(result).toBe(value);
|
||||
});
|
||||
});
|
||||
});
|
184
libs/tools/generator/core/src/strategies/storage.ts
Normal file
184
libs/tools/generator/core/src/strategies/storage.ts
Normal file
@ -0,0 +1,184 @@
|
||||
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||
|
||||
import {
|
||||
PassphraseGenerationOptions,
|
||||
PasswordGenerationOptions,
|
||||
CatchallGenerationOptions,
|
||||
EffUsernameGenerationOptions,
|
||||
ApiOptions,
|
||||
EmailDomainOptions,
|
||||
EmailPrefixOptions,
|
||||
SelfHostedApiOptions,
|
||||
SubaddressGenerationOptions,
|
||||
} from "../types";
|
||||
|
||||
/** plaintext password generation options */
|
||||
export const PASSWORD_SETTINGS = new UserKeyDefinition<PasswordGenerationOptions>(
|
||||
GENERATOR_DISK,
|
||||
"passwordGeneratorSettings",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/** plaintext passphrase generation options */
|
||||
export const PASSPHRASE_SETTINGS = new UserKeyDefinition<PassphraseGenerationOptions>(
|
||||
GENERATOR_DISK,
|
||||
"passphraseGeneratorSettings",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/** plaintext username generation options */
|
||||
export const EFF_USERNAME_SETTINGS = new UserKeyDefinition<EffUsernameGenerationOptions>(
|
||||
GENERATOR_DISK,
|
||||
"effUsernameGeneratorSettings",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/** plaintext configuration for a domain catch-all address. */
|
||||
export const CATCHALL_SETTINGS = new UserKeyDefinition<CatchallGenerationOptions>(
|
||||
GENERATOR_DISK,
|
||||
"catchallGeneratorSettings",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/** plaintext configuration for an email subaddress. */
|
||||
export const SUBADDRESS_SETTINGS = new UserKeyDefinition<SubaddressGenerationOptions>(
|
||||
GENERATOR_DISK,
|
||||
"subaddressGeneratorSettings",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/** backing store configuration for {@link Forwarders.AddyIo} */
|
||||
export const ADDY_IO_FORWARDER = new UserKeyDefinition<SelfHostedApiOptions & EmailDomainOptions>(
|
||||
GENERATOR_DISK,
|
||||
"addyIoForwarder",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/** backing store configuration for {@link Forwarders.DuckDuckGo} */
|
||||
export const DUCK_DUCK_GO_FORWARDER = new UserKeyDefinition<ApiOptions>(
|
||||
GENERATOR_DISK,
|
||||
"duckDuckGoForwarder",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/** backing store configuration for {@link Forwarders.FastMail} */
|
||||
export const FASTMAIL_FORWARDER = new UserKeyDefinition<ApiOptions & EmailPrefixOptions>(
|
||||
GENERATOR_DISK,
|
||||
"fastmailForwarder",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/** backing store configuration for {@link Forwarders.FireFoxRelay} */
|
||||
export const FIREFOX_RELAY_FORWARDER = new UserKeyDefinition<ApiOptions>(
|
||||
GENERATOR_DISK,
|
||||
"firefoxRelayForwarder",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/** backing store configuration for {@link Forwarders.ForwardEmail} */
|
||||
export const FORWARD_EMAIL_FORWARDER = new UserKeyDefinition<ApiOptions & EmailDomainOptions>(
|
||||
GENERATOR_DISK,
|
||||
"forwardEmailForwarder",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/** backing store configuration for {@link forwarders.SimpleLogin} */
|
||||
export const SIMPLE_LOGIN_FORWARDER = new UserKeyDefinition<SelfHostedApiOptions>(
|
||||
GENERATOR_DISK,
|
||||
"simpleLoginForwarder",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
/** backing store configuration for {@link Forwarders.AddyIo} */
|
||||
export const ADDY_IO_BUFFER = new BufferedKeyDefinition<SelfHostedApiOptions & EmailDomainOptions>(
|
||||
GENERATOR_DISK,
|
||||
"addyIoBuffer",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
/** backing store configuration for {@link Forwarders.DuckDuckGo} */
|
||||
export const DUCK_DUCK_GO_BUFFER = new BufferedKeyDefinition<ApiOptions>(
|
||||
GENERATOR_DISK,
|
||||
"duckDuckGoBuffer",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
/** backing store configuration for {@link Forwarders.FastMail} */
|
||||
export const FASTMAIL_BUFFER = new BufferedKeyDefinition<ApiOptions & EmailPrefixOptions>(
|
||||
GENERATOR_DISK,
|
||||
"fastmailBuffer",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
/** backing store configuration for {@link Forwarders.FireFoxRelay} */
|
||||
export const FIREFOX_RELAY_BUFFER = new BufferedKeyDefinition<ApiOptions>(
|
||||
GENERATOR_DISK,
|
||||
"firefoxRelayBuffer",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
/** backing store configuration for {@link Forwarders.ForwardEmail} */
|
||||
export const FORWARD_EMAIL_BUFFER = new BufferedKeyDefinition<ApiOptions & EmailDomainOptions>(
|
||||
GENERATOR_DISK,
|
||||
"forwardEmailBuffer",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
/** backing store configuration for {@link forwarders.SimpleLogin} */
|
||||
export const SIMPLE_LOGIN_BUFFER = new BufferedKeyDefinition<SelfHostedApiOptions>(
|
||||
GENERATOR_DISK,
|
||||
"simpleLoginBuffer",
|
||||
{
|
||||
deserializer: (value) => value,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
@ -0,0 +1,75 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of, firstValueFrom } from "rxjs";
|
||||
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
// FIXME: use index.ts imports once policy abstractions and models
|
||||
// implement ADR-0002
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { Randomizer } from "../abstractions";
|
||||
import { DefaultSubaddressOptions } from "../data";
|
||||
import { DefaultPolicyEvaluator } from "../policies";
|
||||
|
||||
import { SUBADDRESS_SETTINGS } from "./storage";
|
||||
import { SubaddressGeneratorStrategy } from "./subaddress-generator-strategy";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
const SomePolicy = mock<Policy>({
|
||||
type: PolicyType.PasswordGenerator,
|
||||
data: {
|
||||
minLength: 10,
|
||||
},
|
||||
});
|
||||
|
||||
describe("Email subaddress list generation strategy", () => {
|
||||
describe("toEvaluator()", () => {
|
||||
it.each([[[]], [null], [undefined], [[SomePolicy]], [[SomePolicy, SomePolicy]]])(
|
||||
"should map any input (= %p) to the default policy evaluator",
|
||||
async (policies) => {
|
||||
const strategy = new SubaddressGeneratorStrategy(null, null);
|
||||
|
||||
const evaluator$ = of(policies).pipe(strategy.toEvaluator());
|
||||
const evaluator = await firstValueFrom(evaluator$);
|
||||
|
||||
expect(evaluator).toBeInstanceOf(DefaultPolicyEvaluator);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("durableState", () => {
|
||||
it("should use password settings key", () => {
|
||||
const provider = mock<StateProvider>();
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new SubaddressGeneratorStrategy(randomizer, provider);
|
||||
|
||||
strategy.durableState(SomeUser);
|
||||
|
||||
expect(provider.getUser).toHaveBeenCalledWith(SomeUser, SUBADDRESS_SETTINGS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaults$", () => {
|
||||
it("should return the default subaddress options", async () => {
|
||||
const strategy = new SubaddressGeneratorStrategy(null, null);
|
||||
|
||||
const result = await firstValueFrom(strategy.defaults$(SomeUser));
|
||||
|
||||
expect(result).toEqual(DefaultSubaddressOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy", () => {
|
||||
it("should use password generator policy", () => {
|
||||
const randomizer = mock<Randomizer>();
|
||||
const strategy = new SubaddressGeneratorStrategy(randomizer, null);
|
||||
|
||||
expect(strategy.policy).toBe(PolicyType.PasswordGenerator);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generate()", () => {
|
||||
it.todo("generate email subaddress tests");
|
||||
});
|
||||
});
|
@ -0,0 +1,62 @@
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { GeneratorStrategy, Randomizer } from "../abstractions";
|
||||
import { DefaultSubaddressOptions } from "../data";
|
||||
import { newDefaultEvaluator } from "../rx";
|
||||
import { SubaddressGenerationOptions, NoPolicy } from "../types";
|
||||
import { clone$PerUserId, sharedStateByUserId } from "../util";
|
||||
|
||||
import { SUBADDRESS_SETTINGS } from "./storage";
|
||||
|
||||
/** Strategy for creating an email subaddress
|
||||
* @remarks The subaddress is the part following the `+`.
|
||||
* For example, if the email address is `jd+xyz@domain.io`,
|
||||
* the subaddress is `xyz`.
|
||||
*/
|
||||
export class SubaddressGeneratorStrategy
|
||||
implements GeneratorStrategy<SubaddressGenerationOptions, NoPolicy>
|
||||
{
|
||||
/** Instantiates the generation strategy
|
||||
* @param usernameService generates an email subaddress from an email address
|
||||
*/
|
||||
constructor(
|
||||
private random: Randomizer,
|
||||
private stateProvider: StateProvider,
|
||||
private defaultOptions: SubaddressGenerationOptions = DefaultSubaddressOptions,
|
||||
) {}
|
||||
|
||||
// configuration
|
||||
durableState = sharedStateByUserId(SUBADDRESS_SETTINGS, this.stateProvider);
|
||||
defaults$ = clone$PerUserId(this.defaultOptions);
|
||||
toEvaluator = newDefaultEvaluator<SubaddressGenerationOptions>();
|
||||
readonly policy = PolicyType.PasswordGenerator;
|
||||
|
||||
// algorithm
|
||||
async generate(options: SubaddressGenerationOptions) {
|
||||
const o = Object.assign({}, DefaultSubaddressOptions, options);
|
||||
|
||||
const subaddressEmail = o.subaddressEmail;
|
||||
if (subaddressEmail == null || subaddressEmail.length < 3) {
|
||||
return o.subaddressEmail;
|
||||
}
|
||||
const atIndex = subaddressEmail.indexOf("@");
|
||||
if (atIndex < 1 || atIndex >= subaddressEmail.length - 1) {
|
||||
return subaddressEmail;
|
||||
}
|
||||
if (o.subaddressType == null) {
|
||||
o.subaddressType = "random";
|
||||
}
|
||||
|
||||
const emailBeginning = subaddressEmail.substr(0, atIndex);
|
||||
const emailEnding = subaddressEmail.substr(atIndex + 1, subaddressEmail.length);
|
||||
|
||||
let subaddressString = "";
|
||||
if (o.subaddressType === "random") {
|
||||
subaddressString = await this.random.chars(8);
|
||||
} else if (o.subaddressType === "website-name") {
|
||||
subaddressString = o.website;
|
||||
}
|
||||
return emailBeginning + "+" + subaddressString + "@" + emailEnding;
|
||||
}
|
||||
}
|
4
libs/tools/generator/core/src/types/boundary.ts
Normal file
4
libs/tools/generator/core/src/types/boundary.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export type Boundary = {
|
||||
readonly min: number;
|
||||
readonly max: number;
|
||||
};
|
@ -0,0 +1,14 @@
|
||||
import { RequestOptions } from "./forwarder-options";
|
||||
import { UsernameGenerationMode } from "./generator-options";
|
||||
|
||||
/** Settings supported when generating an email subaddress */
|
||||
export type CatchallGenerationOptions = {
|
||||
/** selects the generation algorithm for the catchall email address. */
|
||||
catchallType?: UsernameGenerationMode;
|
||||
|
||||
/** The domain part of the generated email address.
|
||||
* @example If the domain is `domain.io` and the generated username
|
||||
* is `jd`, then the generated email address will be `jd@mydomain.io`
|
||||
*/
|
||||
catchallDomain?: string;
|
||||
} & RequestOptions;
|
@ -0,0 +1,10 @@
|
||||
import { RequestOptions } from "./forwarder-options";
|
||||
|
||||
/** Settings supported when generating a username using the EFF word list */
|
||||
export type EffUsernameGenerationOptions = {
|
||||
/** when true, the word is capitalized */
|
||||
wordCapitalize?: boolean;
|
||||
|
||||
/** when true, a random number is appended to the username */
|
||||
wordIncludeNumber?: boolean;
|
||||
} & RequestOptions;
|
72
libs/tools/generator/core/src/types/forwarder-options.ts
Normal file
72
libs/tools/generator/core/src/types/forwarder-options.ts
Normal file
@ -0,0 +1,72 @@
|
||||
/** Identifiers for email forwarding services.
|
||||
* @remarks These are used to select forwarder-specific options.
|
||||
* The must be kept in sync with the forwarder implementations.
|
||||
*/
|
||||
export type ForwarderId =
|
||||
| "anonaddy"
|
||||
| "duckduckgo"
|
||||
| "fastmail"
|
||||
| "firefoxrelay"
|
||||
| "forwardemail"
|
||||
| "simplelogin";
|
||||
|
||||
/** Metadata format for email forwarding services. */
|
||||
export type ForwarderMetadata = {
|
||||
/** The unique identifier for the forwarder. */
|
||||
id: ForwarderId;
|
||||
|
||||
/** The name of the service the forwarder queries. */
|
||||
name: string;
|
||||
|
||||
/** Whether the forwarder is valid for self-hosted instances of Bitwarden. */
|
||||
validForSelfHosted: boolean;
|
||||
};
|
||||
|
||||
/** Options common to all forwarder APIs */
|
||||
export type ApiOptions = {
|
||||
/** bearer token that authenticates bitwarden to the forwarder.
|
||||
* This is required to issue an API request.
|
||||
*/
|
||||
token?: string;
|
||||
} & RequestOptions;
|
||||
|
||||
/** Options that provide contextual information about the application state
|
||||
* when a forwarder is invoked.
|
||||
* @remarks these fields should always be omitted when saving options.
|
||||
*/
|
||||
export type RequestOptions = {
|
||||
/** @param website The domain of the website the generated email is used
|
||||
* within. This should be set to `null` when the request is not specific
|
||||
* to any website.
|
||||
*/
|
||||
website: string | null;
|
||||
};
|
||||
|
||||
/** Api configuration for forwarders that support self-hosted installations. */
|
||||
export type SelfHostedApiOptions = ApiOptions & {
|
||||
/** The base URL of the forwarder's API.
|
||||
* When this is empty, the forwarder's default production API is used.
|
||||
*/
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
/** Api configuration for forwarders that support custom domains. */
|
||||
export type EmailDomainOptions = {
|
||||
/** The domain part of the generated email address.
|
||||
* @remarks The domain should be authorized by the forwarder before
|
||||
* submitting a request through bitwarden.
|
||||
* @example If the domain is `domain.io` and the generated username
|
||||
* is `jd`, then the generated email address will be `jd@mydomain.io`
|
||||
*/
|
||||
domain: string;
|
||||
};
|
||||
|
||||
/** Api configuration for forwarders that support custom email parts. */
|
||||
export type EmailPrefixOptions = EmailDomainOptions & {
|
||||
/** A prefix joined to the generated email address' username.
|
||||
* @example If the prefix is `foo`, the generated username is `bar`,
|
||||
* and the domain is `domain.io`, then the generated email address is `
|
||||
* then the generated username is `foobar@domain.io`.
|
||||
*/
|
||||
prefix: string;
|
||||
};
|
13
libs/tools/generator/core/src/types/generator-options.ts
Normal file
13
libs/tools/generator/core/src/types/generator-options.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/** ways you can generate usernames
|
||||
* "word" generates a username from the eff word list
|
||||
* "subaddress" creates a subaddress of an email.
|
||||
* "catchall" uses a domain's catchall address
|
||||
* "forwarded" uses an email forwarding service
|
||||
*/
|
||||
export type UsernameGeneratorType = "word" | "subaddress" | "catchall" | "forwarded";
|
||||
|
||||
/** Several username generators support two generation modes
|
||||
* "random" selects one or more random words from the EFF word list
|
||||
* "website-name" includes the domain in the generated username
|
||||
*/
|
||||
export type UsernameGenerationMode = "random" | "website-name";
|
2
libs/tools/generator/core/src/types/generator-type.ts
Normal file
2
libs/tools/generator/core/src/types/generator-type.ts
Normal file
@ -0,0 +1,2 @@
|
||||
/** The kind of credential being generated. */
|
||||
export type GeneratorType = "password" | "passphrase" | "username";
|
14
libs/tools/generator/core/src/types/index.ts
Normal file
14
libs/tools/generator/core/src/types/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export * from "./boundary";
|
||||
export * from "./catchall-generator-options";
|
||||
export * from "./eff-username-generator-options";
|
||||
export * from "./forwarder-options";
|
||||
export * from "./generator-options";
|
||||
export * from "./generator-type";
|
||||
export * from "./no-policy";
|
||||
export * from "./passphrase-generation-options";
|
||||
export * from "./passphrase-generator-policy";
|
||||
export * from "./password-generation-options";
|
||||
export * from "./password-generator-policy";
|
||||
export * from "./policy-configuration";
|
||||
export * from "./subaddress-generator-options";
|
||||
export * from "./word-options";
|
2
libs/tools/generator/core/src/types/no-policy.ts
Normal file
2
libs/tools/generator/core/src/types/no-policy.ts
Normal file
@ -0,0 +1,2 @@
|
||||
/** Type representing an absence of policy. */
|
||||
export type NoPolicy = Record<string, never>;
|
@ -0,0 +1,26 @@
|
||||
/** Request format for passphrase credential generation.
|
||||
* The members of this type may be `undefined` when the user is
|
||||
* generating a password.
|
||||
*/
|
||||
export type PassphraseGenerationOptions = {
|
||||
/** The number of words to include in the passphrase.
|
||||
* This value defaults to 3.
|
||||
*/
|
||||
numWords?: number;
|
||||
|
||||
/** The ASCII separator character to use between words in the passphrase.
|
||||
* This value defaults to a dash.
|
||||
* If multiple characters appear in the string, only the first character is used.
|
||||
*/
|
||||
wordSeparator?: string;
|
||||
|
||||
/** `true` when the first character of every word should be capitalized.
|
||||
* This value defaults to `false`.
|
||||
*/
|
||||
capitalize?: boolean;
|
||||
|
||||
/** `true` when a number should be included in the passphrase.
|
||||
* This value defaults to `false`.
|
||||
*/
|
||||
includeNumber?: boolean;
|
||||
};
|
@ -0,0 +1,6 @@
|
||||
/** Policy options enforced during passphrase generation. */
|
||||
export type PassphraseGeneratorPolicy = {
|
||||
minNumberWords: number;
|
||||
capitalize: boolean;
|
||||
includeNumber: boolean;
|
||||
};
|
@ -0,0 +1,68 @@
|
||||
/** Request format for password credential generation.
|
||||
* All members of this type may be `undefined` when the user is
|
||||
* generating a passphrase.
|
||||
*
|
||||
* @remarks The name of this type is a bit of a misnomer. This type
|
||||
* it is used with the "password generator" types. The name
|
||||
* `PasswordGeneratorOptions` is already in use by legacy code.
|
||||
*/
|
||||
export type PasswordGenerationOptions = {
|
||||
/** The length of the password selected by the user */
|
||||
length?: number;
|
||||
|
||||
/** The minimum length of the password. This defaults to 5, and increases
|
||||
* to ensure `minLength` is at least as large as the sum of the other minimums.
|
||||
*/
|
||||
minLength?: number;
|
||||
|
||||
/** `true` when ambiguous characters may be included in the output.
|
||||
* `false` when ambiguous characters should not be included in the output.
|
||||
*/
|
||||
ambiguous?: boolean;
|
||||
|
||||
/** `true` when uppercase ASCII characters should be included in the output
|
||||
* This value defaults to `false.
|
||||
*/
|
||||
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;
|
||||
|
||||
/** `true` when lowercase ASCII characters should be included in the output.
|
||||
* This value defaults to `false`.
|
||||
*/
|
||||
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;
|
||||
|
||||
/** 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;
|
||||
|
||||
/** 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;
|
||||
|
||||
/** 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;
|
||||
|
||||
/** 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;
|
||||
};
|
@ -0,0 +1,39 @@
|
||||
/** Policy options enforced during password generation. */
|
||||
export type PasswordGeneratorPolicy = {
|
||||
/** The minimum length of generated passwords.
|
||||
* When this is less than or equal to zero, it is ignored.
|
||||
* If this is less than the total number of characters required by
|
||||
* the policy's other settings, then it is ignored.
|
||||
*/
|
||||
minLength: number;
|
||||
|
||||
/** When this is true, an uppercase character must be part of
|
||||
* the generated password.
|
||||
*/
|
||||
useUppercase: boolean;
|
||||
|
||||
/** When this is true, a lowercase character must be part of
|
||||
* the generated password.
|
||||
*/
|
||||
useLowercase: boolean;
|
||||
|
||||
/** When this is true, at least one digit must be part of the generated
|
||||
* password.
|
||||
*/
|
||||
useNumbers: boolean;
|
||||
|
||||
/** The quantity of digits to include in the generated password.
|
||||
* When this is less than or equal to zero, it is ignored.
|
||||
*/
|
||||
numberCount: number;
|
||||
|
||||
/** When this is true, at least one digit must be part of the generated
|
||||
* password.
|
||||
*/
|
||||
useSpecial: boolean;
|
||||
|
||||
/** The quantity of special characters to include in the generated
|
||||
* password. When this is less than or equal to zero, it is ignored.
|
||||
*/
|
||||
specialCount: number;
|
||||
};
|
16
libs/tools/generator/core/src/types/policy-configuration.ts
Normal file
16
libs/tools/generator/core/src/types/policy-configuration.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
|
||||
/** Determines how to construct a password generator policy */
|
||||
export type PolicyConfiguration<Policy, Evaluator> = {
|
||||
/** The value of the policy when it is not in effect. */
|
||||
disabledValue: Policy;
|
||||
|
||||
/** Combines multiple policies set by the administrative console into
|
||||
* a single policy.
|
||||
*/
|
||||
combine: (acc: Policy, policy: AdminPolicy) => Policy;
|
||||
|
||||
/** Converts policy service data into an actionable policy.
|
||||
*/
|
||||
createEvaluator: (policy: Policy) => Evaluator;
|
||||
};
|
@ -0,0 +1,11 @@
|
||||
import { RequestOptions } from "./forwarder-options";
|
||||
import { UsernameGenerationMode } from "./generator-options";
|
||||
|
||||
/** Settings supported when generating an email subaddress */
|
||||
export type SubaddressGenerationOptions = {
|
||||
/** selects the generation algorithm for the catchall email address. */
|
||||
subaddressType?: UsernameGenerationMode;
|
||||
|
||||
/** the email address the subaddress is applied to. */
|
||||
subaddressEmail?: string;
|
||||
} & RequestOptions;
|
6
libs/tools/generator/core/src/types/word-options.ts
Normal file
6
libs/tools/generator/core/src/types/word-options.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export type WordOptions = {
|
||||
/** set the first letter uppercase */
|
||||
titleCase?: boolean;
|
||||
/** append a number */
|
||||
number?: boolean;
|
||||
};
|
45
libs/tools/generator/core/src/util.ts
Normal file
45
libs/tools/generator/core/src/util.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import {
|
||||
SingleUserState,
|
||||
StateProvider,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
/** construct a method that outputs a copy of `defaultValue` as an observable. */
|
||||
export function clone$PerUserId<Value>(defaultValue: Value) {
|
||||
const _subjects = new Map<UserId, BehaviorSubject<Value>>();
|
||||
|
||||
return (key: UserId) => {
|
||||
let value = _subjects.get(key);
|
||||
|
||||
if (value === undefined) {
|
||||
value = new BehaviorSubject({ ...defaultValue });
|
||||
_subjects.set(key, value);
|
||||
}
|
||||
|
||||
return value.asObservable();
|
||||
};
|
||||
}
|
||||
|
||||
/** construct a method that caches user-specific states by userid. */
|
||||
export function sharedByUserId<Value>(create: (userId: UserId) => SingleUserState<Value>) {
|
||||
const _subjects = new Map<UserId, SingleUserState<Value>>();
|
||||
|
||||
return (key: UserId) => {
|
||||
let value = _subjects.get(key);
|
||||
|
||||
if (value === undefined) {
|
||||
value = create(key);
|
||||
_subjects.set(key, value);
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
/** construct a method that loads a user-specific state from the provider. */
|
||||
export function sharedStateByUserId<Value>(key: UserKeyDefinition<Value>, provider: StateProvider) {
|
||||
return (id: UserId) => provider.getUser<Value>(id, key);
|
||||
}
|
@ -1,5 +1,9 @@
|
||||
{
|
||||
"extends": "../../../shared/tsconfig.libs",
|
||||
"include": ["src"],
|
||||
"include": [
|
||||
"src",
|
||||
"../extensions/src/history/generator-history.abstraction.ts",
|
||||
"../extensions/src/navigation/generator-navigation.service.abstraction.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
@ -8,6 +8,6 @@ module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "../../../shared/test.environment.ts",
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||
prefix: "<rootDir>/../../../",
|
||||
prefix: "<rootDir>/../../",
|
||||
}),
|
||||
};
|
||||
|
@ -0,0 +1,58 @@
|
||||
import { GeneratorCategory, GeneratedCredential } from "./";
|
||||
|
||||
describe("GeneratedCredential", () => {
|
||||
describe("constructor", () => {
|
||||
it("assigns credential", () => {
|
||||
const result = new GeneratedCredential("example", "passphrase", new Date(100));
|
||||
|
||||
expect(result.credential).toEqual("example");
|
||||
});
|
||||
|
||||
it("assigns category", () => {
|
||||
const result = new GeneratedCredential("example", "passphrase", new Date(100));
|
||||
|
||||
expect(result.category).toEqual("passphrase");
|
||||
});
|
||||
|
||||
it("passes through date parameters", () => {
|
||||
const result = new GeneratedCredential("example", "password", new Date(100));
|
||||
|
||||
expect(result.generationDate).toEqual(new Date(100));
|
||||
});
|
||||
|
||||
it("converts numeric dates to Dates", () => {
|
||||
const result = new GeneratedCredential("example", "password", 100);
|
||||
|
||||
expect(result.generationDate).toEqual(new Date(100));
|
||||
});
|
||||
});
|
||||
|
||||
it("toJSON converts from a credential into a JSON object", () => {
|
||||
const credential = new GeneratedCredential("example", "password", new Date(100));
|
||||
|
||||
const result = credential.toJSON();
|
||||
|
||||
expect(result).toEqual({
|
||||
credential: "example",
|
||||
category: "password" as GeneratorCategory,
|
||||
generationDate: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("fromJSON converts Json objects into credentials", () => {
|
||||
const jsonValue = {
|
||||
credential: "example",
|
||||
category: "password" as GeneratorCategory,
|
||||
generationDate: 100,
|
||||
};
|
||||
|
||||
const result = GeneratedCredential.fromJSON(jsonValue);
|
||||
|
||||
expect(result).toBeInstanceOf(GeneratedCredential);
|
||||
expect(result).toEqual({
|
||||
credential: "example",
|
||||
category: "password",
|
||||
generationDate: new Date(100),
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,47 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { GeneratorCategory } from "./options";
|
||||
|
||||
/** A credential generation result */
|
||||
export class GeneratedCredential {
|
||||
/**
|
||||
* Instantiates a generated credential
|
||||
* @param credential The value of the generated credential (e.g. a password)
|
||||
* @param category The kind of credential
|
||||
* @param generationDate The date that the credential was generated.
|
||||
* Numeric values should are interpreted using {@link Date.valueOf}
|
||||
* semantics.
|
||||
*/
|
||||
constructor(
|
||||
readonly credential: string,
|
||||
readonly category: GeneratorCategory,
|
||||
generationDate: Date | number,
|
||||
) {
|
||||
if (typeof generationDate === "number") {
|
||||
this.generationDate = new Date(generationDate);
|
||||
} else {
|
||||
this.generationDate = generationDate;
|
||||
}
|
||||
}
|
||||
|
||||
/** The date that the credential was generated */
|
||||
generationDate: Date;
|
||||
|
||||
/** Constructs a credential from its `toJSON` representation */
|
||||
static fromJSON(jsonValue: Jsonify<GeneratedCredential>) {
|
||||
return new GeneratedCredential(
|
||||
jsonValue.credential,
|
||||
jsonValue.category,
|
||||
jsonValue.generationDate,
|
||||
);
|
||||
}
|
||||
|
||||
/** Serializes a credential to a JSON-compatible object */
|
||||
toJSON() {
|
||||
return {
|
||||
credential: this.credential,
|
||||
category: this.category,
|
||||
generationDate: this.generationDate.valueOf(),
|
||||
};
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
export class GeneratedPasswordHistory {
|
||||
password: string;
|
||||
date: number;
|
||||
|
||||
constructor(password: string, date: number) {
|
||||
this.password = password;
|
||||
this.date = date;
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { GeneratedCredential } from "./generated-credential";
|
||||
import { GeneratorCategory } from "./options";
|
||||
|
||||
/** Tracks the history of password generations.
|
||||
* Each user gets their own store.
|
||||
*/
|
||||
export abstract class GeneratorHistoryService {
|
||||
/** Tracks a new credential. When an item with the same `credential` value
|
||||
* is found, this method does nothing. When the total number of items exceeds
|
||||
* {@link HistoryServiceOptions.maxTotal}, then the oldest items exceeding the total
|
||||
* are deleted.
|
||||
* @param userId identifies the user storing the credential.
|
||||
* @param credential stored by the history service.
|
||||
* @param date when the credential was generated. If this is omitted, then the generator
|
||||
* uses the date the credential was added to the store instead.
|
||||
* @returns a promise that completes with the added credential. If the credential
|
||||
* wasn't added, then the promise completes with `null`.
|
||||
* @remarks this service is not suitable for use with vault items/ciphers. It models only
|
||||
* a history of an individually generated credential, while a vault item's history
|
||||
* may contain several credentials that are better modelled as atomic versions of the
|
||||
* vault item itself.
|
||||
*/
|
||||
track: (
|
||||
userId: UserId,
|
||||
credential: string,
|
||||
category: GeneratorCategory,
|
||||
date?: Date,
|
||||
) => Promise<GeneratedCredential | null>;
|
||||
|
||||
/** Removes a matching credential from the history service.
|
||||
* @param userId identifies the user taking the credential.
|
||||
* @param credential to match in the history service.
|
||||
* @returns A promise that completes with the credential read. If the credential wasn't found,
|
||||
* the promise completes with null.
|
||||
* @remarks this can be used to extract an entry when a credential is stored in the vault.
|
||||
*/
|
||||
take: (userId: UserId, credential: string) => Promise<GeneratedCredential | null>;
|
||||
|
||||
/** Deletes a user's credential history.
|
||||
* @param userId identifies the user taking the credential.
|
||||
* @returns A promise that completes when the history is cleared.
|
||||
*/
|
||||
clear: (userId: UserId) => Promise<GeneratedCredential[]>;
|
||||
|
||||
/** Lists all credentials for a user.
|
||||
* @param userId identifies the user listing the credential.
|
||||
* @remarks This field is eventually consistent with `track` and `take` operations.
|
||||
* It is not guaranteed to immediately reflect those changes.
|
||||
*/
|
||||
credentials$: (userId: UserId) => Observable<GeneratedCredential[]>;
|
||||
}
|
5
libs/tools/generator/extensions/src/history/index.ts
Normal file
5
libs/tools/generator/extensions/src/history/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { GeneratedCredential } from "./generated-credential";
|
||||
export { GeneratedPasswordHistory } from "./generated-password-history";
|
||||
export { GeneratorHistoryService } from "./generator-history.abstraction";
|
||||
export { LocalGeneratorHistoryService } from "./local-generator-history.service";
|
||||
export { GeneratorCategory } from "./options";
|
@ -0,0 +1,65 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { GeneratedCredential } from "./generated-credential";
|
||||
import { GeneratedPasswordHistory } from "./generated-password-history";
|
||||
import { GENERATOR_HISTORY_BUFFER } from "./key-definitions";
|
||||
import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor";
|
||||
|
||||
describe("Key definitions", () => {
|
||||
describe("GENERATOR_HISTORY_BUFFER", () => {
|
||||
describe("options.deserializer", () => {
|
||||
it("should deserialize generated password history", () => {
|
||||
const value: any = [{ password: "foo", date: 1 }];
|
||||
|
||||
const [result] = GENERATOR_HISTORY_BUFFER.options.deserializer(value);
|
||||
|
||||
expect(result).toEqual(value[0]);
|
||||
expect(result).toBeInstanceOf(GeneratedPasswordHistory);
|
||||
});
|
||||
|
||||
it.each([[undefined], [null]])("should ignore nullish (= %p) history", (value: any) => {
|
||||
const result = GENERATOR_HISTORY_BUFFER.options.deserializer(value);
|
||||
|
||||
expect(result).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("should map generated password history to generated credentials", async () => {
|
||||
const value: any = [new GeneratedPasswordHistory("foo", 1)];
|
||||
const decryptor = mock<LegacyPasswordHistoryDecryptor>({
|
||||
decrypt(value) {
|
||||
return Promise.resolve(value);
|
||||
},
|
||||
});
|
||||
|
||||
const [result] = await GENERATOR_HISTORY_BUFFER.map(value, decryptor);
|
||||
|
||||
expect(result).toEqual({
|
||||
credential: "foo",
|
||||
category: "password",
|
||||
generationDate: new Date(1),
|
||||
});
|
||||
expect(result).toBeInstanceOf(GeneratedCredential);
|
||||
});
|
||||
|
||||
describe("isValid", () => {
|
||||
it("should accept histories with at least one entry", async () => {
|
||||
const value: any = [new GeneratedPasswordHistory("foo", 1)];
|
||||
const decryptor = {} as any;
|
||||
|
||||
const result = await GENERATOR_HISTORY_BUFFER.isValid(value, decryptor);
|
||||
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
|
||||
it("should reject histories with no entries", async () => {
|
||||
const value: any = [];
|
||||
const decryptor = {} as any;
|
||||
|
||||
const result = await GENERATOR_HISTORY_BUFFER.isValid(value, decryptor);
|
||||
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user