1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-22 11:45:59 +01:00

[PM-8809] delete common generator code (#10218)

This commit is contained in:
✨ Audrey ✨ 2024-07-25 12:08:18 -04:00 committed by GitHub
parent c755512770
commit bb5f56838a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
100 changed files with 0 additions and 9580 deletions

View File

@ -1,53 +0,0 @@
import { Observable } from "rxjs";
import { UserId } from "../../../types/guid";
import { GeneratedCredential, GeneratorCategory } from "../history";
/** 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[]>;
}

View File

@ -1,42 +0,0 @@
import { Observable } from "rxjs";
import { UserId } from "../../../types/guid";
import { GeneratorNavigation } from "../navigation/generator-navigation";
import { GeneratorNavigationPolicy } from "../navigation/generator-navigation-policy";
import { PolicyEvaluator } from "./policy-evaluator.abstraction";
/** Loads and stores generator navigational data
*/
export abstract class GeneratorNavigationService {
/** 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<GeneratorNavigation>;
/** Gets the default options. */
defaults$: (userId: UserId) => Observable<GeneratorNavigation>;
/** 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<GeneratorNavigationPolicy, GeneratorNavigation>>;
/** 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: GeneratorNavigation) => Promise<GeneratorNavigation>;
/** Saves the navigation 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: GeneratorNavigation) => Promise<void>;
}

View File

@ -1,42 +0,0 @@
import { Observable } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy as AdminPolicy } from "../../../admin-console/models/domain/policy";
import { SingleUserState } from "../../../platform/state";
import { UserId } from "../../../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>;
}

View File

@ -1,46 +0,0 @@
import { Observable } from "rxjs";
import { UserId } from "../../../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>;
}

View File

@ -1,5 +0,0 @@
export { GeneratorHistoryService } from "./generator-history.abstraction";
export { GeneratorNavigationService } from "./generator-navigation.service.abstraction";
export { GeneratorService } from "./generator.service.abstraction";
export { GeneratorStrategy } from "./generator-strategy.abstraction";
export { PolicyEvaluator } from "./policy-evaluator.abstraction";

View File

@ -1,20 +0,0 @@
import { Observable } from "rxjs";
import { PasswordGeneratorPolicyOptions } from "../../../admin-console/models/domain/password-generator-policy-options";
import { GeneratedPasswordHistory } from "../password/generated-password-history";
import { PasswordGeneratorOptions } from "../password/password-generator-options";
/** @deprecated Use {@link GeneratorService} with a password or passphrase {@link GeneratorStrategy} instead. */
export abstract class PasswordGenerationServiceAbstraction {
generatePassword: (options: PasswordGeneratorOptions) => Promise<string>;
generatePassphrase: (options: PasswordGeneratorOptions) => Promise<string>;
getOptions: () => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>;
getOptions$: () => Observable<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>;
enforcePasswordGeneratorPoliciesOnOptions: (
options: PasswordGeneratorOptions,
) => Promise<[PasswordGeneratorOptions, PasswordGeneratorPolicyOptions]>;
saveOptions: (options: PasswordGeneratorOptions) => Promise<void>;
getHistory: () => Promise<GeneratedPasswordHistory[]>;
addHistory: (password: string) => Promise<void>;
clear: (userId?: string) => Promise<GeneratedPasswordHistory[]>;
}

View File

@ -1,28 +0,0 @@
/** 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;
}

View File

@ -1,39 +0,0 @@
import { WordOptions } from "../word-options";
/** 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>;
}

View File

@ -1,15 +0,0 @@
import { Observable } from "rxjs";
import { UsernameGeneratorOptions } from "../username/username-generation-options";
/** @deprecated Use {@link GeneratorService} with a username {@link GeneratorStrategy} instead. */
export abstract class UsernameGenerationServiceAbstraction {
generateUsername: (options: UsernameGeneratorOptions) => Promise<string>;
generateWord: (options: UsernameGeneratorOptions) => Promise<string>;
generateSubaddress: (options: UsernameGeneratorOptions) => Promise<string>;
generateCatchall: (options: UsernameGeneratorOptions) => Promise<string>;
generateForwarded: (options: UsernameGeneratorOptions) => Promise<string>;
getOptions: () => Promise<UsernameGeneratorOptions>;
getOptions$: () => Observable<UsernameGeneratorOptions>;
saveOptions: (options: UsernameGeneratorOptions) => Promise<void>;
}

View File

@ -1,199 +0,0 @@
/**
* include structuredClone in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { mock } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, map, pipe } from "rxjs";
import { FakeSingleUserState, awaitAsync } from "../../../spec";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../admin-console/models/domain/policy";
import { SingleUserState } from "../../platform/state";
import { UserId } from "../../types/guid";
import { GeneratorStrategy, PolicyEvaluator } from "./abstractions";
import { PasswordGenerationOptions } from "./password";
import { DefaultGeneratorService } from ".";
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();
});
});
});

View File

@ -1,72 +0,0 @@
import { firstValueFrom, Observable } from "rxjs";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { UserId } from "../../types/guid";
import { GeneratorStrategy, GeneratorService, PolicyEvaluator } from "./abstractions";
/** {@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,
) {}
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(),
);
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);
}
}

View File

@ -1,43 +0,0 @@
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);
});
});
});

View File

@ -1,27 +0,0 @@
import { PolicyEvaluator } from "./abstractions";
import { NoPolicy } from "./no-policy";
/** 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;
}
}

View File

@ -1,5 +0,0 @@
// this export provided solely for backwards compatibility
export {
/** @deprecated use `GeneratorNavigation` from './navigation' instead. */
GeneratorNavigation as GeneratorOptions,
} from "./navigation/generator-navigation";

View File

@ -1,2 +0,0 @@
/** The kind of credential being generated. */
export type GeneratorType = "password" | "passphrase" | "username";

View File

@ -1,58 +0,0 @@
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),
});
});
});

View File

@ -1,47 +0,0 @@
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(),
};
}
}

View File

@ -1,2 +0,0 @@
export { GeneratorCategory } from "./options";
export { GeneratedCredential } from "./generated-credential";

View File

@ -1,29 +0,0 @@
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { EncString } from "../../../platform/models/domain/enc-string";
import { UserId } from "../../../types/guid";
import { GeneratedPasswordHistory } from "../password/generated-password-history";
/** Strategy that decrypts a password history */
export class LegacyPasswordHistoryDecryptor {
constructor(
private userId: UserId,
private cryptoService: CryptoService,
private encryptService: EncryptService,
) {}
/** Decrypts a password history. */
async decrypt(history: GeneratedPasswordHistory[]): Promise<GeneratedPasswordHistory[]> {
const key = await this.cryptoService.getUserKey(this.userId);
const promises = (history ?? []).map(async (item) => {
const encrypted = new EncString(item.password);
const decrypted = await this.encryptService.decryptToUtf8(encrypted, key);
return new GeneratedPasswordHistory(decrypted, item.date);
});
const decrypted = await Promise.all(promises);
return decrypted;
}
}

View File

@ -1,199 +0,0 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { FakeStateProvider, awaitAsync, mockAccountServiceWith } from "../../../../spec";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { EncString } from "../../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "../../../types/csprng";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { LocalGeneratorHistoryService } from "./local-generator-history.service";
const SomeUser = "SomeUser" as UserId;
const AnotherUser = "AnotherUser" as UserId;
describe("LocalGeneratorHistoryService", () => {
const encryptService = mock<EncryptService>();
const keyService = mock<CryptoService>();
const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey;
beforeEach(() => {
encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString));
encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString));
keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey));
keyService.getInMemoryUserKeyFor$.mockImplementation(() => of(true as unknown as UserKey));
});
afterEach(() => {
jest.resetAllMocks();
});
describe("credential$", () => {
it("returns an empty list when no credentials are stored", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
const result = await firstValueFrom(history.credentials$(SomeUser));
expect(result).toEqual([]);
});
});
describe("track", () => {
it("stores a password", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "example", "password");
await awaitAsync();
const [result] = await firstValueFrom(history.credentials$(SomeUser));
expect(result).toMatchObject({ credential: "example", category: "password" });
});
it("stores a passphrase", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "example", "passphrase");
await awaitAsync();
const [result] = await firstValueFrom(history.credentials$(SomeUser));
expect(result).toMatchObject({ credential: "example", category: "passphrase" });
});
it("stores a specific date when one is provided", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "example", "password", new Date(100));
await awaitAsync();
const [result] = await firstValueFrom(history.credentials$(SomeUser));
expect(result).toEqual({
credential: "example",
category: "password",
generationDate: new Date(100),
});
});
it("skips storing a credential when it's already stored (ignores category)", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "example", "password");
await history.track(SomeUser, "example", "password");
await history.track(SomeUser, "example", "passphrase");
await awaitAsync();
const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser));
expect(firstResult).toMatchObject({ credential: "example", category: "password" });
expect(secondResult).toBeUndefined();
});
it("stores multiple credentials when the credential value is different", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "secondResult", "password");
await history.track(SomeUser, "firstResult", "password");
await awaitAsync();
const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser));
expect(firstResult).toMatchObject({ credential: "firstResult", category: "password" });
expect(secondResult).toMatchObject({ credential: "secondResult", category: "password" });
});
it("removes history items exceeding maxTotal configuration", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider, {
maxTotal: 1,
});
await history.track(SomeUser, "removed result", "password");
await history.track(SomeUser, "example", "password");
await awaitAsync();
const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser));
expect(firstResult).toMatchObject({ credential: "example", category: "password" });
expect(secondResult).toBeUndefined();
});
it("stores history items in per-user collections", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider, {
maxTotal: 1,
});
await history.track(SomeUser, "some user example", "password");
await history.track(AnotherUser, "another user example", "password");
await awaitAsync();
const [someFirstResult, someSecondResult] = await firstValueFrom(
history.credentials$(SomeUser),
);
const [anotherFirstResult, anotherSecondResult] = await firstValueFrom(
history.credentials$(AnotherUser),
);
expect(someFirstResult).toMatchObject({
credential: "some user example",
category: "password",
});
expect(someSecondResult).toBeUndefined();
expect(anotherFirstResult).toMatchObject({
credential: "another user example",
category: "password",
});
expect(anotherSecondResult).toBeUndefined();
});
});
describe("take", () => {
it("returns null when there are no credentials stored", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
const result = await history.take(SomeUser, "example");
expect(result).toBeNull();
});
it("returns null when the credential wasn't found", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "example", "password");
const result = await history.take(SomeUser, "not found");
expect(result).toBeNull();
});
it("returns a matching credential", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "example", "password");
const result = await history.take(SomeUser, "example");
expect(result).toMatchObject({
credential: "example",
category: "password",
});
});
it("removes a matching credential", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "example", "password");
await history.take(SomeUser, "example");
await awaitAsync();
const results = await firstValueFrom(history.credentials$(SomeUser));
expect(results).toEqual([]);
});
});
});

View File

@ -1,145 +0,0 @@
import { map } from "rxjs";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { SingleUserState, StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { BufferedState } from "../../state/buffered-state";
import { PaddedDataPacker } from "../../state/padded-data-packer";
import { SecretState } from "../../state/secret-state";
import { UserKeyEncryptor } from "../../state/user-key-encryptor";
import { GeneratorHistoryService } from "../abstractions/generator-history.abstraction";
import { GENERATOR_HISTORY, GENERATOR_HISTORY_BUFFER } from "../key-definitions";
import { GeneratedCredential } from "./generated-credential";
import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor";
import { GeneratorCategory, HistoryServiceOptions } from "./options";
const OPTIONS_FRAME_SIZE = 2048;
/** Tracks the history of password generations local to a device.
* {@link GeneratorHistoryService}
*/
export class LocalGeneratorHistoryService extends GeneratorHistoryService {
constructor(
private readonly encryptService: EncryptService,
private readonly keyService: CryptoService,
private readonly stateProvider: StateProvider,
private readonly options: HistoryServiceOptions = { maxTotal: 100 },
) {
super();
}
private _credentialStates = new Map<UserId, SingleUserState<GeneratedCredential[]>>();
/** {@link GeneratorHistoryService.track} */
track = async (userId: UserId, credential: string, category: GeneratorCategory, date?: Date) => {
const state = this.getCredentialState(userId);
let result: GeneratedCredential = null;
await state.update(
(credentials) => {
credentials = credentials ?? [];
// add the result
result = new GeneratedCredential(credential, category, date ?? Date.now());
credentials.unshift(result);
// trim history
const removeAt = Math.max(0, this.options.maxTotal);
credentials.splice(removeAt, Infinity);
return credentials;
},
{
shouldUpdate: (credentials) =>
!(credentials?.some((f) => f.credential === credential) ?? false),
},
);
return result;
};
/** {@link GeneratorHistoryService.take} */
take = async (userId: UserId, credential: string) => {
const state = this.getCredentialState(userId);
let credentialIndex: number;
let result: GeneratedCredential = null;
await state.update(
(credentials) => {
credentials = credentials ?? [];
[result] = credentials.splice(credentialIndex, 1);
return credentials;
},
{
shouldUpdate: (credentials) => {
credentialIndex = credentials?.findIndex((f) => f.credential === credential) ?? -1;
return credentialIndex >= 0;
},
},
);
return result;
};
/** {@link GeneratorHistoryService.take} */
clear = async (userId: UserId) => {
const state = this.getCredentialState(userId);
const result = (await state.update(() => null)) ?? [];
return result;
};
/** {@link GeneratorHistoryService.credentials$} */
credentials$ = (userId: UserId) => {
return this.getCredentialState(userId).state$.pipe(map((credentials) => credentials ?? []));
};
private getCredentialState(userId: UserId) {
let state = this._credentialStates.get(userId);
if (!state) {
state = this.createSecretState(userId);
this._credentialStates.set(userId, state);
}
return state;
}
private createSecretState(userId: UserId): SingleUserState<GeneratedCredential[]> {
// construct the encryptor
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE);
const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer);
// construct the durable state
const state = SecretState.from<
GeneratedCredential[],
number,
GeneratedCredential,
Record<keyof GeneratedCredential, never>,
GeneratedCredential
>(userId, GENERATOR_HISTORY, this.stateProvider, encryptor);
// decryptor is just an algorithm, but it can't run until the key is available;
// providing it via an observable makes running it early impossible
const decryptor = new LegacyPasswordHistoryDecryptor(
userId,
this.keyService,
this.encryptService,
);
const decryptor$ = this.keyService
.getInMemoryUserKeyFor$(userId)
.pipe(map((key) => key && decryptor));
// move data from the old password history once decryptor is available
const buffer = new BufferedState(
this.stateProvider,
GENERATOR_HISTORY_BUFFER,
state,
decryptor$,
);
return buffer;
}
}

View File

@ -1,10 +0,0 @@
/** Kinds of credentials that can be stored by the history service */
export type GeneratorCategory = "password" | "passphrase";
/** Configuration options for the history service */
export type HistoryServiceOptions = {
/** Total number of records retained across all types.
* @remarks Setting this to 0 or less disables history completely.
* */
maxTotal: number;
};

View File

@ -1,6 +0,0 @@
export * from "./abstractions/index";
export * from "./password/index";
export { DefaultGeneratorService } from "./default-generator.service";
export { legacyPasswordGenerationServiceFactory } from "./legacy-password-generation.service";
export { legacyUsernameGenerationServiceFactory } from "./legacy-username-generation.service";

View File

@ -1,241 +0,0 @@
import { mock } from "jest-mock-extended";
import { GeneratedCredential } from "./history";
import { LegacyPasswordHistoryDecryptor } from "./history/legacy-password-history-decryptor";
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,
GENERATOR_SETTINGS,
ADDY_IO_BUFFER,
DUCK_DUCK_GO_BUFFER,
FASTMAIL_BUFFER,
FIREFOX_RELAY_BUFFER,
FORWARD_EMAIL_BUFFER,
SIMPLE_LOGIN_BUFFER,
GENERATOR_HISTORY_BUFFER,
} from "./key-definitions";
import { GeneratedPasswordHistory } from "./password";
describe("Key definitions", () => {
describe("GENERATOR_SETTINGS", () => {
it("should pass through deserialization", () => {
const value = {};
const result = GENERATOR_SETTINGS.deserializer(value);
expect(result).toBe(value);
});
});
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);
});
});
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);
});
});
});
});

View File

@ -1,234 +0,0 @@
import { Jsonify } from "type-fest";
import { GENERATOR_DISK, UserKeyDefinition } from "../../platform/state";
import { BufferedKeyDefinition } from "../state/buffered-key-definition";
import { SecretClassifier } from "../state/secret-classifier";
import { SecretKeyDefinition } from "../state/secret-key-definition";
import { GeneratedCredential } from "./history/generated-credential";
import { LegacyPasswordHistoryDecryptor } from "./history/legacy-password-history-decryptor";
import { GeneratorNavigation } from "./navigation/generator-navigation";
import { PassphraseGenerationOptions } from "./passphrase/passphrase-generation-options";
import { GeneratedPasswordHistory } from "./password/generated-password-history";
import { PasswordGenerationOptions } from "./password/password-generation-options";
import { CatchallGenerationOptions } from "./username/catchall-generator-options";
import { EffUsernameGenerationOptions } from "./username/eff-username-generator-options";
import {
ApiOptions,
EmailDomainOptions,
EmailPrefixOptions,
SelfHostedApiOptions,
} from "./username/options/forwarder-options";
import { SubaddressGenerationOptions } from "./username/subaddress-generator-options";
/** plaintext password generation options */
export const GENERATOR_SETTINGS = new UserKeyDefinition<GeneratorNavigation>(
GENERATOR_DISK,
"generatorSettings",
{
deserializer: (value) => value,
clearOn: ["logout"],
},
);
/** 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"],
},
);
/** encrypted password generation history */
export const GENERATOR_HISTORY = SecretKeyDefinition.array(
GENERATOR_DISK,
"localGeneratorHistory",
SecretClassifier.allSecret<GeneratedCredential>(),
{
deserializer: GeneratedCredential.fromJSON,
clearOn: ["logout"],
},
);
/** encrypted password generation history subject to migration */
export const GENERATOR_HISTORY_BUFFER = new BufferedKeyDefinition<
GeneratedPasswordHistory[],
GeneratedCredential[],
LegacyPasswordHistoryDecryptor
>(GENERATOR_DISK, "localGeneratorHistoryBuffer", {
deserializer(history) {
const items = history as Jsonify<GeneratedPasswordHistory>[];
return items?.map((h) => new GeneratedPasswordHistory(h.password, h.date));
},
async isValid(history) {
return history.length ? true : false;
},
async map(history, decryptor) {
const credentials = await decryptor.decrypt(history);
const mapped = credentials.map((c) => new GeneratedCredential(c.password, "password", c.date));
return mapped;
},
clearOn: ["logout"],
});

View File

@ -1,567 +0,0 @@
/**
* include structuredClone in test environment.
* @jest-environment ../../../shared/test.environment.ts
*/
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { mockAccountServiceWith } from "../../../spec";
import { UserId } from "../../types/guid";
import {
GeneratorHistoryService,
GeneratorNavigationService,
GeneratorService,
} from "./abstractions";
import { GeneratedCredential } from "./history";
import { LegacyPasswordGenerationService } from "./legacy-password-generation.service";
import { DefaultGeneratorNavigation, GeneratorNavigation } from "./navigation/generator-navigation";
import { GeneratorNavigationEvaluator } from "./navigation/generator-navigation-evaluator";
import { GeneratorNavigationPolicy } from "./navigation/generator-navigation-policy";
import {
DefaultPassphraseGenerationOptions,
PassphraseGenerationOptions,
PassphraseGeneratorOptionsEvaluator,
PassphraseGeneratorPolicy,
} from "./passphrase";
import { DisabledPassphraseGeneratorPolicy } from "./passphrase/passphrase-generator-policy";
import {
DefaultPasswordGenerationOptions,
GeneratedPasswordHistory,
PasswordGenerationOptions,
PasswordGeneratorOptions,
PasswordGeneratorOptionsEvaluator,
PasswordGeneratorPolicy,
} from "./password";
import { DisabledPasswordGeneratorPolicy } from "./password/password-generator-policy";
const SomeUser = "some user" as UserId;
function createPassphraseGenerator(
options: PassphraseGenerationOptions = {},
policy: PassphraseGeneratorPolicy = DisabledPassphraseGeneratorPolicy,
) {
let savedOptions = options;
const generator = mock<GeneratorService<PassphraseGenerationOptions, PassphraseGeneratorPolicy>>({
evaluator$(id: UserId) {
const evaluator = new PassphraseGeneratorOptionsEvaluator(policy);
return of(evaluator);
},
options$(id: UserId) {
return of(savedOptions);
},
defaults$(id: UserId) {
return of(DefaultPassphraseGenerationOptions);
},
saveOptions(userId, options) {
savedOptions = options;
return Promise.resolve();
},
});
return generator;
}
function createPasswordGenerator(
options: PasswordGenerationOptions = {},
policy: PasswordGeneratorPolicy = DisabledPasswordGeneratorPolicy,
) {
let savedOptions = options;
const generator = mock<GeneratorService<PasswordGenerationOptions, PasswordGeneratorPolicy>>({
evaluator$(id: UserId) {
const evaluator = new PasswordGeneratorOptionsEvaluator(policy);
return of(evaluator);
},
options$(id: UserId) {
return of(savedOptions);
},
defaults$(id: UserId) {
return of(DefaultPasswordGenerationOptions);
},
saveOptions(userId, options) {
savedOptions = options;
return Promise.resolve();
},
});
return generator;
}
function createNavigationGenerator(
options: GeneratorNavigation = {},
policy: GeneratorNavigationPolicy = {},
) {
let savedOptions = options;
const generator = mock<GeneratorNavigationService>({
evaluator$(id: UserId) {
const evaluator = new GeneratorNavigationEvaluator(policy);
return of(evaluator);
},
options$(id: UserId) {
return of(savedOptions);
},
defaults$(id: UserId) {
return of(DefaultGeneratorNavigation);
},
saveOptions: jest.fn((userId, options) => {
savedOptions = options;
return Promise.resolve();
}),
});
return generator;
}
describe("LegacyPasswordGenerationService", () => {
// NOTE: in all tests, `null` constructor arguments are not used by the test.
// They're set to `null` to avoid setting up unnecessary mocks.
describe("generatePassword", () => {
it("invokes the inner password generator to generate passwords", async () => {
const innerPassword = createPasswordGenerator();
const generator = new LegacyPasswordGenerationService(null, null, innerPassword, null, null);
const options = { type: "password" } as PasswordGeneratorOptions;
await generator.generatePassword(options);
expect(innerPassword.generate).toHaveBeenCalledWith(options);
});
it("invokes the inner passphrase generator to generate passphrases", async () => {
const innerPassphrase = createPassphraseGenerator();
const generator = new LegacyPasswordGenerationService(
null,
null,
null,
innerPassphrase,
null,
);
const options = { type: "passphrase" } as PasswordGeneratorOptions;
await generator.generatePassword(options);
expect(innerPassphrase.generate).toHaveBeenCalledWith(options);
});
});
describe("generatePassphrase", () => {
it("invokes the inner passphrase generator", async () => {
const innerPassphrase = createPassphraseGenerator();
const generator = new LegacyPasswordGenerationService(
null,
null,
null,
innerPassphrase,
null,
);
const options = {} as PasswordGeneratorOptions;
await generator.generatePassphrase(options);
expect(innerPassphrase.generate).toHaveBeenCalledWith(options);
});
});
describe("getOptions", () => {
it("combines options from its inner services", async () => {
const innerPassword = createPasswordGenerator({
length: 29,
minLength: 20,
ambiguous: false,
uppercase: true,
minUppercase: 1,
lowercase: false,
minLowercase: 2,
number: true,
minNumber: 3,
special: false,
minSpecial: 0,
});
const innerPassphrase = createPassphraseGenerator({
numWords: 10,
wordSeparator: "-",
capitalize: true,
includeNumber: false,
});
const navigation = createNavigationGenerator({
type: "passphrase",
username: "word",
forwarder: "simplelogin",
});
const accountService = mockAccountServiceWith(SomeUser);
const generator = new LegacyPasswordGenerationService(
accountService,
navigation,
innerPassword,
innerPassphrase,
null,
);
const [result] = await generator.getOptions();
expect(result).toEqual({
type: "passphrase",
length: 29,
minLength: 5,
ambiguous: false,
uppercase: true,
minUppercase: 1,
lowercase: false,
minLowercase: 0,
number: true,
minNumber: 3,
special: false,
minSpecial: 0,
numWords: 10,
wordSeparator: "-",
capitalize: true,
includeNumber: false,
policyUpdated: true,
});
});
it("sets default options when an inner service lacks a value", async () => {
const innerPassword = createPasswordGenerator(null);
const innerPassphrase = createPassphraseGenerator(null);
const navigation = createNavigationGenerator(null);
const accountService = mockAccountServiceWith(SomeUser);
const generator = new LegacyPasswordGenerationService(
accountService,
navigation,
innerPassword,
innerPassphrase,
null,
);
const [result] = await generator.getOptions();
expect(result).toEqual({
type: DefaultGeneratorNavigation.type,
...DefaultPassphraseGenerationOptions,
...DefaultPasswordGenerationOptions,
minLowercase: 1,
minUppercase: 1,
policyUpdated: true,
});
});
it("combines policies from its inner services", async () => {
const innerPassword = createPasswordGenerator(
{},
{
minLength: 20,
numberCount: 10,
specialCount: 11,
useUppercase: true,
useLowercase: false,
useNumbers: true,
useSpecial: false,
},
);
const innerPassphrase = createPassphraseGenerator(
{},
{
minNumberWords: 5,
capitalize: true,
includeNumber: false,
},
);
const accountService = mockAccountServiceWith(SomeUser);
const navigation = createNavigationGenerator(
{},
{
defaultType: "password",
},
);
const generator = new LegacyPasswordGenerationService(
accountService,
navigation,
innerPassword,
innerPassphrase,
null,
);
const [, policy] = await generator.getOptions();
expect(policy).toEqual({
defaultType: "password",
minLength: 20,
numberCount: 10,
specialCount: 11,
useUppercase: true,
useLowercase: false,
useNumbers: true,
useSpecial: false,
minNumberWords: 5,
capitalize: true,
includeNumber: false,
});
});
});
describe("enforcePasswordGeneratorPoliciesOnOptions", () => {
it("returns its options parameter with password policy applied", async () => {
const innerPassword = createPasswordGenerator(
{},
{
minLength: 15,
numberCount: 5,
specialCount: 5,
useUppercase: true,
useLowercase: true,
useNumbers: true,
useSpecial: true,
},
);
const innerPassphrase = createPassphraseGenerator();
const accountService = mockAccountServiceWith(SomeUser);
const navigation = createNavigationGenerator();
const options = {
type: "password" as const,
};
const generator = new LegacyPasswordGenerationService(
accountService,
navigation,
innerPassword,
innerPassphrase,
null,
);
const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options);
expect(result).toBe(options);
expect(result).toMatchObject({
length: 15,
minLength: 15,
minLowercase: 1,
minNumber: 5,
minUppercase: 1,
minSpecial: 5,
uppercase: true,
lowercase: true,
number: true,
special: true,
});
});
it("returns its options parameter with passphrase policy applied", async () => {
const innerPassword = createPasswordGenerator();
const innerPassphrase = createPassphraseGenerator(
{},
{
minNumberWords: 5,
capitalize: true,
includeNumber: true,
},
);
const accountService = mockAccountServiceWith(SomeUser);
const navigation = createNavigationGenerator();
const options = {
type: "passphrase" as const,
};
const generator = new LegacyPasswordGenerationService(
accountService,
navigation,
innerPassword,
innerPassphrase,
null,
);
const [result] = await generator.enforcePasswordGeneratorPoliciesOnOptions(options);
expect(result).toBe(options);
expect(result).toMatchObject({
numWords: 5,
capitalize: true,
includeNumber: true,
});
});
it("returns the applied policy", async () => {
const innerPassword = createPasswordGenerator(
{},
{
minLength: 20,
numberCount: 10,
specialCount: 11,
useUppercase: true,
useLowercase: false,
useNumbers: true,
useSpecial: false,
},
);
const innerPassphrase = createPassphraseGenerator(
{},
{
minNumberWords: 5,
capitalize: true,
includeNumber: false,
},
);
const accountService = mockAccountServiceWith(SomeUser);
const navigation = createNavigationGenerator(
{},
{
defaultType: "password",
},
);
const generator = new LegacyPasswordGenerationService(
accountService,
navigation,
innerPassword,
innerPassphrase,
null,
);
const [, policy] = await generator.enforcePasswordGeneratorPoliciesOnOptions({});
expect(policy).toEqual({
defaultType: "password",
minLength: 20,
numberCount: 10,
specialCount: 11,
useUppercase: true,
useLowercase: false,
useNumbers: true,
useSpecial: false,
minNumberWords: 5,
capitalize: true,
includeNumber: false,
});
});
});
describe("saveOptions", () => {
it("loads saved password options", async () => {
const innerPassword = createPasswordGenerator();
const innerPassphrase = createPassphraseGenerator();
const navigation = createNavigationGenerator();
const accountService = mockAccountServiceWith(SomeUser);
const generator = new LegacyPasswordGenerationService(
accountService,
navigation,
innerPassword,
innerPassphrase,
null,
);
const options = {
type: "password" as const,
length: 29,
minLength: 5,
ambiguous: false,
uppercase: true,
minUppercase: 1,
lowercase: false,
minLowercase: 0,
number: true,
minNumber: 3,
special: false,
minSpecial: 0,
};
await generator.saveOptions(options);
const [result] = await generator.getOptions();
expect(result).toMatchObject(options);
});
it("loads saved passphrase options", async () => {
const innerPassword = createPasswordGenerator();
const innerPassphrase = createPassphraseGenerator();
const navigation = createNavigationGenerator();
const accountService = mockAccountServiceWith(SomeUser);
const generator = new LegacyPasswordGenerationService(
accountService,
navigation,
innerPassword,
innerPassphrase,
null,
);
const options = {
type: "passphrase" as const,
numWords: 10,
wordSeparator: "-",
capitalize: true,
includeNumber: false,
};
await generator.saveOptions(options);
const [result] = await generator.getOptions();
expect(result).toMatchObject(options);
});
it("preserves saved navigation options", async () => {
const innerPassword = createPasswordGenerator();
const innerPassphrase = createPassphraseGenerator();
const navigation = createNavigationGenerator({
type: "password",
username: "forwarded",
forwarder: "firefoxrelay",
});
const accountService = mockAccountServiceWith(SomeUser);
const generator = new LegacyPasswordGenerationService(
accountService,
navigation,
innerPassword,
innerPassphrase,
null,
);
const options = {
type: "passphrase" as const,
numWords: 10,
wordSeparator: "-",
capitalize: true,
includeNumber: false,
};
await generator.saveOptions(options);
expect(navigation.saveOptions).toHaveBeenCalledWith(SomeUser, {
type: "passphrase",
username: "forwarded",
forwarder: "firefoxrelay",
});
});
});
describe("getHistory", () => {
it("gets the active user's history from the history service", async () => {
const history = mock<GeneratorHistoryService>();
history.credentials$.mockReturnValue(
of([new GeneratedCredential("foo", "password", new Date(100))]),
);
const accountService = mockAccountServiceWith(SomeUser);
const generator = new LegacyPasswordGenerationService(
accountService,
null,
null,
null,
history,
);
const result = await generator.getHistory();
expect(history.credentials$).toHaveBeenCalledWith(SomeUser);
expect(result).toEqual([new GeneratedPasswordHistory("foo", 100)]);
});
});
describe("addHistory", () => {
it("adds a history item as a password credential", async () => {
const history = mock<GeneratorHistoryService>();
const accountService = mockAccountServiceWith(SomeUser);
const generator = new LegacyPasswordGenerationService(
accountService,
null,
null,
null,
history,
);
await generator.addHistory("foo");
expect(history.track).toHaveBeenCalledWith(SomeUser, "foo", "password");
});
});
});

View File

@ -1,426 +0,0 @@
import {
concatMap,
zip,
map,
firstValueFrom,
combineLatest,
pairwise,
of,
concat,
Observable,
filter,
timeout,
} from "rxjs";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { PasswordGeneratorPolicyOptions } from "../../admin-console/models/domain/password-generator-policy-options";
import { AccountService } from "../../auth/abstractions/account.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { StateProvider } from "../../platform/state";
import {
GeneratorHistoryService,
GeneratorService,
GeneratorNavigationService,
PolicyEvaluator,
} from "./abstractions";
import { PasswordGenerationServiceAbstraction } from "./abstractions/password-generation.service.abstraction";
import { DefaultGeneratorService } from "./default-generator.service";
import { GeneratedCredential } from "./history";
import { LocalGeneratorHistoryService } from "./history/local-generator-history.service";
import { GeneratorNavigation } from "./navigation";
import { DefaultGeneratorNavigationService } from "./navigation/default-generator-navigation.service";
import { GeneratorNavigationPolicy } from "./navigation/generator-navigation-policy";
import {
PassphraseGenerationOptions,
PassphraseGeneratorPolicy,
PassphraseGeneratorStrategy,
} from "./passphrase";
import {
GeneratedPasswordHistory,
PasswordGenerationOptions,
PasswordGeneratorOptions,
PasswordGeneratorPolicy,
PasswordGeneratorStrategy,
} from "./password";
import { CryptoServiceRandomizer } from "./random";
type MappedOptions = {
generator: GeneratorNavigation;
password: PasswordGenerationOptions;
passphrase: PassphraseGenerationOptions;
policyUpdated: boolean;
};
export function legacyPasswordGenerationServiceFactory(
encryptService: EncryptService,
cryptoService: CryptoService,
policyService: PolicyService,
accountService: AccountService,
stateProvider: StateProvider,
): PasswordGenerationServiceAbstraction {
const randomizer = new CryptoServiceRandomizer(cryptoService);
const passwords = new DefaultGeneratorService(
new PasswordGeneratorStrategy(randomizer, stateProvider),
policyService,
);
const passphrases = new DefaultGeneratorService(
new PassphraseGeneratorStrategy(randomizer, stateProvider),
policyService,
);
const navigation = new DefaultGeneratorNavigationService(stateProvider, policyService);
const history = new LocalGeneratorHistoryService(encryptService, cryptoService, stateProvider);
return new LegacyPasswordGenerationService(
accountService,
navigation,
passwords,
passphrases,
history,
);
}
/** Adapts the generator 2.0 design to 1.0 angular services. */
export class LegacyPasswordGenerationService implements PasswordGenerationServiceAbstraction {
constructor(
private readonly accountService: AccountService,
private readonly navigation: GeneratorNavigationService,
private readonly passwords: GeneratorService<
PasswordGenerationOptions,
PasswordGeneratorPolicy
>,
private readonly passphrases: GeneratorService<
PassphraseGenerationOptions,
PassphraseGeneratorPolicy
>,
private readonly history: GeneratorHistoryService,
) {}
generatePassword(options: PasswordGeneratorOptions) {
if (options.type === "password") {
return this.passwords.generate(options);
} else {
return this.passphrases.generate(options);
}
}
generatePassphrase(options: PasswordGeneratorOptions) {
return this.passphrases.generate(options);
}
private getRawOptions$() {
// give the typechecker a nudge to avoid "implicit any" errors
type RawOptionsIntermediateType = [
PasswordGenerationOptions,
PasswordGenerationOptions,
[PolicyEvaluator<PasswordGeneratorPolicy, PasswordGenerationOptions>, number],
PassphraseGenerationOptions,
PassphraseGenerationOptions,
[PolicyEvaluator<PassphraseGeneratorPolicy, PassphraseGenerationOptions>, number],
GeneratorNavigation,
GeneratorNavigation,
[PolicyEvaluator<GeneratorNavigationPolicy, GeneratorNavigation>, number],
];
function withSequenceNumber<T>(observable$: Observable<T>) {
return observable$.pipe(map((evaluator, i) => [evaluator, i] as const));
}
// initial array ensures that destructuring never fails; sequence numbers
// set to `-1` so that the first update reflects that the policy changed from
// "unknown" to "whatever was provided by the service". This needs to be called
// each time the active user changes or the `concat` will block.
function initial$() {
const initial: RawOptionsIntermediateType = [
null,
null,
[null, -1],
null,
null,
[null, -1],
null,
null,
[null, -1],
];
return of(initial);
}
function intermediatePairsToRawOptions([previous, current]: [
RawOptionsIntermediateType,
RawOptionsIntermediateType,
]) {
const [, , [, passwordPrevious], , , [, passphrasePrevious], , , [, generatorPrevious]] =
previous;
const [
passwordOptions,
passwordDefaults,
[passwordEvaluator, passwordCurrent],
passphraseOptions,
passphraseDefaults,
[passphraseEvaluator, passphraseCurrent],
generatorOptions,
generatorDefaults,
[generatorEvaluator, generatorCurrent],
] = current;
// when any of the sequence numbers change, the emission occurs as the result of
// a policy update
const policyEmitted =
passwordPrevious < passwordCurrent ||
passphrasePrevious < passphraseCurrent ||
generatorPrevious < generatorCurrent;
const result = [
passwordOptions,
passwordDefaults,
passwordEvaluator,
passphraseOptions,
passphraseDefaults,
passphraseEvaluator,
generatorOptions,
generatorDefaults,
generatorEvaluator,
policyEmitted,
] as const;
return result;
}
// look upon my works, ye mighty, and despair!
const rawOptions$ = this.accountService.activeAccount$.pipe(
concatMap((activeUser) =>
concat(
initial$(),
combineLatest([
this.passwords.options$(activeUser.id),
this.passwords.defaults$(activeUser.id),
withSequenceNumber(this.passwords.evaluator$(activeUser.id)),
this.passphrases.options$(activeUser.id),
this.passphrases.defaults$(activeUser.id),
withSequenceNumber(this.passphrases.evaluator$(activeUser.id)),
this.navigation.options$(activeUser.id),
this.navigation.defaults$(activeUser.id),
withSequenceNumber(this.navigation.evaluator$(activeUser.id)),
]),
),
),
pairwise(),
map(intermediatePairsToRawOptions),
);
return rawOptions$;
}
getOptions$() {
const options$ = this.getRawOptions$().pipe(
map(
([
passwordOptions,
passwordDefaults,
passwordEvaluator,
passphraseOptions,
passphraseDefaults,
passphraseEvaluator,
generatorOptions,
generatorDefaults,
generatorEvaluator,
policyUpdated,
]) => {
const passwordOptionsWithPolicy = passwordEvaluator.applyPolicy(
passwordOptions ?? passwordDefaults,
);
const passphraseOptionsWithPolicy = passphraseEvaluator.applyPolicy(
passphraseOptions ?? passphraseDefaults,
);
const generatorOptionsWithPolicy = generatorEvaluator.applyPolicy(
generatorOptions ?? generatorDefaults,
);
const options = this.toPasswordGeneratorOptions({
password: passwordEvaluator.sanitize(passwordOptionsWithPolicy),
passphrase: passphraseEvaluator.sanitize(passphraseOptionsWithPolicy),
generator: generatorEvaluator.sanitize(generatorOptionsWithPolicy),
policyUpdated,
});
const policy = Object.assign(
new PasswordGeneratorPolicyOptions(),
passwordEvaluator.policy,
passphraseEvaluator.policy,
generatorEvaluator.policy,
);
return [options, policy] as [PasswordGeneratorOptions, PasswordGeneratorPolicyOptions];
},
),
);
return options$;
}
async getOptions() {
return await firstValueFrom(this.getOptions$());
}
async enforcePasswordGeneratorPoliciesOnOptions(options: PasswordGeneratorOptions) {
const options$ = this.accountService.activeAccount$.pipe(
concatMap((activeUser) =>
zip(
this.passwords.evaluator$(activeUser.id),
this.passphrases.evaluator$(activeUser.id),
this.navigation.evaluator$(activeUser.id),
),
),
map(([passwordEvaluator, passphraseEvaluator, navigationEvaluator]) => {
const policy = Object.assign(
new PasswordGeneratorPolicyOptions(),
passwordEvaluator.policy,
passphraseEvaluator.policy,
navigationEvaluator.policy,
);
const navigationApplied = navigationEvaluator.applyPolicy(options);
const navigationSanitized = {
...options,
...navigationEvaluator.sanitize(navigationApplied),
};
if (options.type === "password") {
const applied = passwordEvaluator.applyPolicy(navigationSanitized);
const sanitized = passwordEvaluator.sanitize(applied);
return [sanitized, policy];
} else {
const applied = passphraseEvaluator.applyPolicy(navigationSanitized);
const sanitized = passphraseEvaluator.sanitize(applied);
return [sanitized, policy];
}
}),
);
const [sanitized, policy] = await firstValueFrom(options$);
return [
// callers assume this function updates the options parameter
Object.assign(options, sanitized),
policy,
] as [PasswordGeneratorOptions, PasswordGeneratorPolicyOptions];
}
async saveOptions(options: PasswordGeneratorOptions) {
const stored = this.toStoredOptions(options);
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
// generator settings needs to preserve whether password or passphrase is selected,
// so `navigationOptions` is mutated.
const navigationOptions$ = zip(
this.navigation.options$(activeAccount.id),
this.navigation.defaults$(activeAccount.id),
).pipe(map(([options, defaults]) => options ?? defaults));
let navigationOptions = await firstValueFrom(navigationOptions$);
navigationOptions = Object.assign(navigationOptions, stored.generator);
await this.navigation.saveOptions(activeAccount.id, navigationOptions);
// overwrite all other settings with latest values
await this.passwords.saveOptions(activeAccount.id, stored.password);
await this.passphrases.saveOptions(activeAccount.id, stored.passphrase);
}
private toStoredOptions(options: PasswordGeneratorOptions): MappedOptions {
return {
generator: {
type: options.type,
},
password: {
length: options.length,
minLength: options.minLength,
ambiguous: options.ambiguous,
uppercase: options.uppercase,
minUppercase: options.minUppercase,
lowercase: options.lowercase,
minLowercase: options.minLowercase,
number: options.number,
minNumber: options.minNumber,
special: options.special,
minSpecial: options.minSpecial,
},
passphrase: {
numWords: options.numWords,
wordSeparator: options.wordSeparator,
capitalize: options.capitalize,
includeNumber: options.includeNumber,
},
policyUpdated: false,
};
}
private toPasswordGeneratorOptions(options: MappedOptions): PasswordGeneratorOptions {
return {
type: options.generator.type,
length: options.password.length,
minLength: options.password.minLength,
ambiguous: options.password.ambiguous,
uppercase: options.password.uppercase,
minUppercase: options.password.minUppercase,
lowercase: options.password.lowercase,
minLowercase: options.password.minLowercase,
number: options.password.number,
minNumber: options.password.minNumber,
special: options.password.special,
minSpecial: options.password.minSpecial,
numWords: options.passphrase.numWords,
wordSeparator: options.passphrase.wordSeparator,
capitalize: options.passphrase.capitalize,
includeNumber: options.passphrase.includeNumber,
policyUpdated: options.policyUpdated,
};
}
getHistory() {
const history = this.accountService.activeAccount$.pipe(
concatMap((account) => this.history.credentials$(account.id)),
timeout({
// timeout after 1 second
each: 1000,
with() {
return [];
},
}),
map((history) => history.map(toGeneratedPasswordHistory)),
);
return firstValueFrom(history);
}
async addHistory(password: string) {
const account = await firstValueFrom(this.accountService.activeAccount$);
if (account?.id) {
// legacy service doesn't distinguish credential types
await this.history.track(account.id, password, "password");
}
}
clear() {
const history$ = this.accountService.activeAccount$.pipe(
filter((account) => !!account?.id),
concatMap((account) => this.history.clear(account.id)),
timeout({
// timeout after 1 second
each: 1000,
with() {
return [];
},
}),
map((history) => history.map(toGeneratedPasswordHistory)),
);
return firstValueFrom(history$);
}
}
function toGeneratedPasswordHistory(value: GeneratedCredential) {
return new GeneratedPasswordHistory(value.credential, value.generationDate.valueOf());
}

View File

@ -1,748 +0,0 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { mockAccountServiceWith } from "../../../spec";
import { UserId } from "../../types/guid";
import { GeneratorNavigationService, GeneratorService } from "./abstractions";
import { DefaultPolicyEvaluator } from "./default-policy-evaluator";
import { LegacyUsernameGenerationService } from "./legacy-username-generation.service";
import { DefaultGeneratorNavigation, GeneratorNavigation } from "./navigation/generator-navigation";
import { GeneratorNavigationEvaluator } from "./navigation/generator-navigation-evaluator";
import { GeneratorNavigationPolicy } from "./navigation/generator-navigation-policy";
import { NoPolicy } from "./no-policy";
import { UsernameGeneratorOptions } from "./username";
import {
CatchallGenerationOptions,
DefaultCatchallOptions,
} from "./username/catchall-generator-options";
import {
DefaultEffUsernameOptions,
EffUsernameGenerationOptions,
} from "./username/eff-username-generator-options";
import { DefaultAddyIoOptions } from "./username/forwarders/addy-io";
import { DefaultDuckDuckGoOptions } from "./username/forwarders/duck-duck-go";
import { DefaultFastmailOptions } from "./username/forwarders/fastmail";
import { DefaultFirefoxRelayOptions } from "./username/forwarders/firefox-relay";
import { DefaultForwardEmailOptions } from "./username/forwarders/forward-email";
import { DefaultSimpleLoginOptions } from "./username/forwarders/simple-login";
import { Forwarders } from "./username/options/constants";
import {
ApiOptions,
EmailDomainOptions,
EmailPrefixOptions,
SelfHostedApiOptions,
} from "./username/options/forwarder-options";
import {
DefaultSubaddressOptions,
SubaddressGenerationOptions,
} from "./username/subaddress-generator-options";
const SomeUser = "userId" as UserId;
function createGenerator<Options>(options: Options, defaults: Options) {
let savedOptions = options;
const generator = mock<GeneratorService<Options, NoPolicy>>({
evaluator$(id: UserId) {
const evaluator = new DefaultPolicyEvaluator<Options>();
return of(evaluator);
},
options$(id: UserId) {
return of(savedOptions);
},
defaults$(id: UserId) {
return of(defaults);
},
saveOptions: jest.fn((userId, options) => {
savedOptions = options;
return Promise.resolve();
}),
});
return generator;
}
function createNavigationGenerator(
options: GeneratorNavigation = {},
policy: GeneratorNavigationPolicy = {},
) {
let savedOptions = options;
const generator = mock<GeneratorNavigationService>({
evaluator$(id: UserId) {
const evaluator = new GeneratorNavigationEvaluator(policy);
return of(evaluator);
},
options$(id: UserId) {
return of(savedOptions);
},
defaults$(id: UserId) {
return of(DefaultGeneratorNavigation);
},
saveOptions: jest.fn((userId, options) => {
savedOptions = options;
return Promise.resolve();
}),
});
return generator;
}
describe("LegacyUsernameGenerationService", () => {
// NOTE: in all tests, `null` constructor arguments are not used by the test.
// They're set to `null` to avoid setting up unnecessary mocks.
describe("generateUserName", () => {
it("should generate a catchall username", async () => {
const options = { type: "catchall" } as UsernameGeneratorOptions;
const catchall = createGenerator<CatchallGenerationOptions>(null, null);
catchall.generate.mockResolvedValue("catchall@example.com");
const generator = new LegacyUsernameGenerationService(
null,
null,
catchall,
null,
null,
null,
null,
null,
null,
null,
null,
);
const result = await generator.generateUsername(options);
expect(catchall.generate).toHaveBeenCalledWith(options);
expect(result).toBe("catchall@example.com");
});
it("should generate an EFF word username", async () => {
const options = { type: "word" } as UsernameGeneratorOptions;
const effWord = createGenerator<EffUsernameGenerationOptions>(null, null);
effWord.generate.mockResolvedValue("eff word");
const generator = new LegacyUsernameGenerationService(
null,
null,
null,
effWord,
null,
null,
null,
null,
null,
null,
null,
);
const result = await generator.generateUsername(options);
expect(effWord.generate).toHaveBeenCalledWith(options);
expect(result).toBe("eff word");
});
it("should generate a subaddress username", async () => {
const options = { type: "subaddress" } as UsernameGeneratorOptions;
const subaddress = createGenerator<SubaddressGenerationOptions>(null, null);
subaddress.generate.mockResolvedValue("subaddress@example.com");
const generator = new LegacyUsernameGenerationService(
null,
null,
null,
null,
subaddress,
null,
null,
null,
null,
null,
null,
);
const result = await generator.generateUsername(options);
expect(subaddress.generate).toHaveBeenCalledWith(options);
expect(result).toBe("subaddress@example.com");
});
it("should generate a forwarder username", async () => {
// set up an arbitrary forwarder for the username test; all forwarders tested in their own tests
const options = {
type: "forwarded",
forwardedService: Forwarders.AddyIo.id,
} as UsernameGeneratorOptions;
const addyIo = createGenerator<SelfHostedApiOptions & EmailDomainOptions>(null, null);
addyIo.generate.mockResolvedValue("addyio@example.com");
const generator = new LegacyUsernameGenerationService(
null,
null,
null,
null,
null,
addyIo,
null,
null,
null,
null,
null,
);
const result = await generator.generateUsername(options);
expect(addyIo.generate).toHaveBeenCalledWith({});
expect(result).toBe("addyio@example.com");
});
});
describe("generateCatchall", () => {
it("should generate a catchall username", async () => {
const options = { type: "catchall" } as UsernameGeneratorOptions;
const catchall = createGenerator<CatchallGenerationOptions>(null, null);
catchall.generate.mockResolvedValue("catchall@example.com");
const generator = new LegacyUsernameGenerationService(
null,
null,
catchall,
null,
null,
null,
null,
null,
null,
null,
null,
);
const result = await generator.generateCatchall(options);
expect(catchall.generate).toHaveBeenCalledWith(options);
expect(result).toBe("catchall@example.com");
});
});
describe("generateSubaddress", () => {
it("should generate a subaddress username", async () => {
const options = { type: "subaddress" } as UsernameGeneratorOptions;
const subaddress = createGenerator<SubaddressGenerationOptions>(null, null);
subaddress.generate.mockResolvedValue("subaddress@example.com");
const generator = new LegacyUsernameGenerationService(
null,
null,
null,
null,
subaddress,
null,
null,
null,
null,
null,
null,
);
const result = await generator.generateSubaddress(options);
expect(subaddress.generate).toHaveBeenCalledWith(options);
expect(result).toBe("subaddress@example.com");
});
});
describe("generateForwarded", () => {
it("should generate a AddyIo username", async () => {
const options = {
forwardedService: Forwarders.AddyIo.id,
forwardedAnonAddyApiToken: "token",
forwardedAnonAddyBaseUrl: "https://example.com",
forwardedAnonAddyDomain: "example.com",
website: "example.com",
} as UsernameGeneratorOptions;
const addyIo = createGenerator<SelfHostedApiOptions & EmailDomainOptions>(null, null);
addyIo.generate.mockResolvedValue("addyio@example.com");
const generator = new LegacyUsernameGenerationService(
null,
null,
null,
null,
null,
addyIo,
null,
null,
null,
null,
null,
);
const result = await generator.generateForwarded(options);
expect(addyIo.generate).toHaveBeenCalledWith({
token: "token",
baseUrl: "https://example.com",
domain: "example.com",
website: "example.com",
});
expect(result).toBe("addyio@example.com");
});
it("should generate a DuckDuckGo username", async () => {
const options = {
forwardedService: Forwarders.DuckDuckGo.id,
forwardedDuckDuckGoToken: "token",
website: "example.com",
} as UsernameGeneratorOptions;
const duckDuckGo = createGenerator<ApiOptions>(null, null);
duckDuckGo.generate.mockResolvedValue("ddg@example.com");
const generator = new LegacyUsernameGenerationService(
null,
null,
null,
null,
null,
null,
duckDuckGo,
null,
null,
null,
null,
);
const result = await generator.generateForwarded(options);
expect(duckDuckGo.generate).toHaveBeenCalledWith({
token: "token",
website: "example.com",
});
expect(result).toBe("ddg@example.com");
});
it("should generate a Fastmail username", async () => {
const options = {
forwardedService: Forwarders.Fastmail.id,
forwardedFastmailApiToken: "token",
website: "example.com",
} as UsernameGeneratorOptions;
const fastmail = createGenerator<ApiOptions & EmailPrefixOptions>(null, null);
fastmail.generate.mockResolvedValue("fastmail@example.com");
const generator = new LegacyUsernameGenerationService(
null,
null,
null,
null,
null,
null,
null,
fastmail,
null,
null,
null,
);
const result = await generator.generateForwarded(options);
expect(fastmail.generate).toHaveBeenCalledWith({
token: "token",
website: "example.com",
});
expect(result).toBe("fastmail@example.com");
});
it("should generate a FirefoxRelay username", async () => {
const options = {
forwardedService: Forwarders.FirefoxRelay.id,
forwardedFirefoxApiToken: "token",
website: "example.com",
} as UsernameGeneratorOptions;
const firefoxRelay = createGenerator<ApiOptions>(null, null);
firefoxRelay.generate.mockResolvedValue("firefoxrelay@example.com");
const generator = new LegacyUsernameGenerationService(
null,
null,
null,
null,
null,
null,
null,
null,
firefoxRelay,
null,
null,
);
const result = await generator.generateForwarded(options);
expect(firefoxRelay.generate).toHaveBeenCalledWith({
token: "token",
website: "example.com",
});
expect(result).toBe("firefoxrelay@example.com");
});
it("should generate a ForwardEmail username", async () => {
const options = {
forwardedService: Forwarders.ForwardEmail.id,
forwardedForwardEmailApiToken: "token",
forwardedForwardEmailDomain: "example.com",
website: "example.com",
} as UsernameGeneratorOptions;
const forwardEmail = createGenerator<ApiOptions & EmailDomainOptions>(null, null);
forwardEmail.generate.mockResolvedValue("forwardemail@example.com");
const generator = new LegacyUsernameGenerationService(
null,
null,
null,
null,
null,
null,
null,
null,
null,
forwardEmail,
null,
);
const result = await generator.generateForwarded(options);
expect(forwardEmail.generate).toHaveBeenCalledWith({
token: "token",
domain: "example.com",
website: "example.com",
});
expect(result).toBe("forwardemail@example.com");
});
it("should generate a SimpleLogin username", async () => {
const options = {
forwardedService: Forwarders.SimpleLogin.id,
forwardedSimpleLoginApiKey: "token",
forwardedSimpleLoginBaseUrl: "https://example.com",
website: "example.com",
} as UsernameGeneratorOptions;
const simpleLogin = createGenerator<SelfHostedApiOptions>(null, null);
simpleLogin.generate.mockResolvedValue("simplelogin@example.com");
const generator = new LegacyUsernameGenerationService(
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
simpleLogin,
);
const result = await generator.generateForwarded(options);
expect(simpleLogin.generate).toHaveBeenCalledWith({
token: "token",
baseUrl: "https://example.com",
website: "example.com",
});
expect(result).toBe("simplelogin@example.com");
});
});
describe("getOptions", () => {
it("combines options from its inner generators", async () => {
const account = mockAccountServiceWith(SomeUser);
const navigation = createNavigationGenerator({
type: "username",
username: "catchall",
forwarder: Forwarders.AddyIo.id,
});
const catchall = createGenerator<CatchallGenerationOptions>(
{
catchallDomain: "example.com",
catchallType: "random",
website: null,
},
null,
);
const effUsername = createGenerator<EffUsernameGenerationOptions>(
{
wordCapitalize: true,
wordIncludeNumber: false,
website: null,
},
null,
);
const subaddress = createGenerator<SubaddressGenerationOptions>(
{
subaddressType: "random",
subaddressEmail: "foo@example.com",
website: null,
},
null,
);
const addyIo = createGenerator<SelfHostedApiOptions & EmailDomainOptions>(
{
token: "addyIoToken",
domain: "addyio.example.com",
baseUrl: "https://addyio.api.example.com",
website: null,
},
null,
);
const duckDuckGo = createGenerator<ApiOptions>(
{
token: "ddgToken",
website: null,
},
null,
);
const fastmail = createGenerator<ApiOptions & EmailPrefixOptions>(
{
token: "fastmailToken",
domain: "fastmail.example.com",
prefix: "foo",
website: null,
},
null,
);
const firefoxRelay = createGenerator<ApiOptions>(
{
token: "firefoxToken",
website: null,
},
null,
);
const forwardEmail = createGenerator<ApiOptions & EmailDomainOptions>(
{
token: "forwardEmailToken",
domain: "example.com",
website: null,
},
null,
);
const simpleLogin = createGenerator<SelfHostedApiOptions>(
{
token: "simpleLoginToken",
baseUrl: "https://simplelogin.api.example.com",
website: null,
},
null,
);
const generator = new LegacyUsernameGenerationService(
account,
navigation,
catchall,
effUsername,
subaddress,
addyIo,
duckDuckGo,
fastmail,
firefoxRelay,
forwardEmail,
simpleLogin,
);
const result = await generator.getOptions();
expect(result).toEqual({
type: "catchall",
wordCapitalize: true,
wordIncludeNumber: false,
subaddressType: "random",
subaddressEmail: "foo@example.com",
catchallType: "random",
catchallDomain: "example.com",
forwardedService: Forwarders.AddyIo.id,
forwardedAnonAddyApiToken: "addyIoToken",
forwardedAnonAddyDomain: "addyio.example.com",
forwardedAnonAddyBaseUrl: "https://addyio.api.example.com",
forwardedDuckDuckGoToken: "ddgToken",
forwardedFirefoxApiToken: "firefoxToken",
forwardedFastmailApiToken: "fastmailToken",
forwardedForwardEmailApiToken: "forwardEmailToken",
forwardedForwardEmailDomain: "example.com",
forwardedSimpleLoginApiKey: "simpleLoginToken",
forwardedSimpleLoginBaseUrl: "https://simplelogin.api.example.com",
});
});
it("sets default options when an inner service lacks a value", async () => {
const account = mockAccountServiceWith(SomeUser);
const navigation = createNavigationGenerator(null);
const catchall = createGenerator<CatchallGenerationOptions>(null, DefaultCatchallOptions);
const effUsername = createGenerator<EffUsernameGenerationOptions>(
null,
DefaultEffUsernameOptions,
);
const subaddress = createGenerator<SubaddressGenerationOptions>(
null,
DefaultSubaddressOptions,
);
const addyIo = createGenerator<SelfHostedApiOptions & EmailDomainOptions>(
null,
DefaultAddyIoOptions,
);
const duckDuckGo = createGenerator<ApiOptions>(null, DefaultDuckDuckGoOptions);
const fastmail = createGenerator<ApiOptions & EmailPrefixOptions>(
null,
DefaultFastmailOptions,
);
const firefoxRelay = createGenerator<ApiOptions>(null, DefaultFirefoxRelayOptions);
const forwardEmail = createGenerator<ApiOptions & EmailDomainOptions>(
null,
DefaultForwardEmailOptions,
);
const simpleLogin = createGenerator<SelfHostedApiOptions>(null, DefaultSimpleLoginOptions);
const generator = new LegacyUsernameGenerationService(
account,
navigation,
catchall,
effUsername,
subaddress,
addyIo,
duckDuckGo,
fastmail,
firefoxRelay,
forwardEmail,
simpleLogin,
);
const result = await generator.getOptions();
expect(result).toEqual({
type: DefaultGeneratorNavigation.username,
catchallType: DefaultCatchallOptions.catchallType,
catchallDomain: DefaultCatchallOptions.catchallDomain,
wordCapitalize: DefaultEffUsernameOptions.wordCapitalize,
wordIncludeNumber: DefaultEffUsernameOptions.wordIncludeNumber,
subaddressType: DefaultSubaddressOptions.subaddressType,
subaddressEmail: DefaultSubaddressOptions.subaddressEmail,
forwardedService: DefaultGeneratorNavigation.forwarder,
forwardedAnonAddyApiToken: DefaultAddyIoOptions.token,
forwardedAnonAddyDomain: DefaultAddyIoOptions.domain,
forwardedAnonAddyBaseUrl: DefaultAddyIoOptions.baseUrl,
forwardedDuckDuckGoToken: DefaultDuckDuckGoOptions.token,
forwardedFastmailApiToken: DefaultFastmailOptions.token,
forwardedFirefoxApiToken: DefaultFirefoxRelayOptions.token,
forwardedForwardEmailApiToken: DefaultForwardEmailOptions.token,
forwardedForwardEmailDomain: DefaultForwardEmailOptions.domain,
forwardedSimpleLoginApiKey: DefaultSimpleLoginOptions.token,
forwardedSimpleLoginBaseUrl: DefaultSimpleLoginOptions.baseUrl,
});
});
});
describe("saveOptions", () => {
it("saves option sets to its inner generators", async () => {
const account = mockAccountServiceWith(SomeUser);
const navigation = createNavigationGenerator({ type: "password" });
const catchall = createGenerator<CatchallGenerationOptions>(null, null);
const effUsername = createGenerator<EffUsernameGenerationOptions>(null, null);
const subaddress = createGenerator<SubaddressGenerationOptions>(null, null);
const addyIo = createGenerator<SelfHostedApiOptions & EmailDomainOptions>(null, null);
const duckDuckGo = createGenerator<ApiOptions>(null, null);
const fastmail = createGenerator<ApiOptions & EmailPrefixOptions>(null, null);
const firefoxRelay = createGenerator<ApiOptions>(null, null);
const forwardEmail = createGenerator<ApiOptions & EmailDomainOptions>(null, null);
const simpleLogin = createGenerator<SelfHostedApiOptions>(null, null);
const generator = new LegacyUsernameGenerationService(
account,
navigation,
catchall,
effUsername,
subaddress,
addyIo,
duckDuckGo,
fastmail,
firefoxRelay,
forwardEmail,
simpleLogin,
);
await generator.saveOptions({
type: "catchall",
wordCapitalize: true,
wordIncludeNumber: false,
subaddressType: "random",
subaddressEmail: "foo@example.com",
catchallType: "random",
catchallDomain: "example.com",
forwardedService: Forwarders.AddyIo.id,
forwardedAnonAddyApiToken: "addyIoToken",
forwardedAnonAddyDomain: "addyio.example.com",
forwardedAnonAddyBaseUrl: "https://addyio.api.example.com",
forwardedDuckDuckGoToken: "ddgToken",
forwardedFirefoxApiToken: "firefoxToken",
forwardedFastmailApiToken: "fastmailToken",
forwardedForwardEmailApiToken: "forwardEmailToken",
forwardedForwardEmailDomain: "example.com",
forwardedSimpleLoginApiKey: "simpleLoginToken",
forwardedSimpleLoginBaseUrl: "https://simplelogin.api.example.com",
website: null,
});
expect(navigation.saveOptions).toHaveBeenCalledWith(SomeUser, {
type: "password",
username: "catchall",
forwarder: Forwarders.AddyIo.id,
});
expect(catchall.saveOptions).toHaveBeenCalledWith(SomeUser, {
catchallDomain: "example.com",
catchallType: "random",
website: null,
});
expect(effUsername.saveOptions).toHaveBeenCalledWith(SomeUser, {
wordCapitalize: true,
wordIncludeNumber: false,
website: null,
});
expect(subaddress.saveOptions).toHaveBeenCalledWith(SomeUser, {
subaddressType: "random",
subaddressEmail: "foo@example.com",
website: null,
});
expect(addyIo.saveOptions).toHaveBeenCalledWith(SomeUser, {
token: "addyIoToken",
domain: "addyio.example.com",
baseUrl: "https://addyio.api.example.com",
website: null,
});
expect(duckDuckGo.saveOptions).toHaveBeenCalledWith(SomeUser, {
token: "ddgToken",
website: null,
});
expect(fastmail.saveOptions).toHaveBeenCalledWith(SomeUser, {
token: "fastmailToken",
website: null,
});
expect(firefoxRelay.saveOptions).toHaveBeenCalledWith(SomeUser, {
token: "firefoxToken",
website: null,
});
expect(forwardEmail.saveOptions).toHaveBeenCalledWith(SomeUser, {
token: "forwardEmailToken",
domain: "example.com",
website: null,
});
expect(simpleLogin.saveOptions).toHaveBeenCalledWith(SomeUser, {
token: "simpleLoginToken",
baseUrl: "https://simplelogin.api.example.com",
website: null,
});
});
});
});

View File

@ -1,390 +0,0 @@
import { zip, firstValueFrom, map, concatMap, combineLatest } from "rxjs";
import { ApiService } from "../../abstractions/api.service";
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "../../auth/abstractions/account.service";
import { CryptoService } from "../../platform/abstractions/crypto.service";
import { EncryptService } from "../../platform/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { StateProvider } from "../../platform/state";
import { GeneratorService, GeneratorNavigationService } from "./abstractions";
import { UsernameGenerationServiceAbstraction } from "./abstractions/username-generation.service.abstraction";
import { DefaultGeneratorService } from "./default-generator.service";
import { DefaultGeneratorNavigationService } from "./navigation/default-generator-navigation.service";
import { GeneratorNavigation } from "./navigation/generator-navigation";
import { NoPolicy } from "./no-policy";
import { CryptoServiceRandomizer } from "./random";
import {
CatchallGeneratorStrategy,
SubaddressGeneratorStrategy,
EffUsernameGeneratorStrategy,
} from "./username";
import { CatchallGenerationOptions } from "./username/catchall-generator-options";
import { EffUsernameGenerationOptions } from "./username/eff-username-generator-options";
import { AddyIoForwarder } from "./username/forwarders/addy-io";
import { DuckDuckGoForwarder } from "./username/forwarders/duck-duck-go";
import { FastmailForwarder } from "./username/forwarders/fastmail";
import { FirefoxRelayForwarder } from "./username/forwarders/firefox-relay";
import { ForwardEmailForwarder } from "./username/forwarders/forward-email";
import { SimpleLoginForwarder } from "./username/forwarders/simple-login";
import { Forwarders } from "./username/options/constants";
import {
ApiOptions,
EmailDomainOptions,
EmailPrefixOptions,
RequestOptions,
SelfHostedApiOptions,
} from "./username/options/forwarder-options";
import { SubaddressGenerationOptions } from "./username/subaddress-generator-options";
import { UsernameGeneratorOptions } from "./username/username-generation-options";
type MappedOptions = {
generator: GeneratorNavigation;
algorithms: {
catchall: CatchallGenerationOptions;
effUsername: EffUsernameGenerationOptions;
subaddress: SubaddressGenerationOptions;
};
forwarders: {
addyIo: SelfHostedApiOptions & EmailDomainOptions & RequestOptions;
duckDuckGo: ApiOptions & RequestOptions;
fastmail: ApiOptions & EmailPrefixOptions & RequestOptions;
firefoxRelay: ApiOptions & RequestOptions;
forwardEmail: ApiOptions & EmailDomainOptions & RequestOptions;
simpleLogin: SelfHostedApiOptions & RequestOptions;
};
};
export function legacyUsernameGenerationServiceFactory(
apiService: ApiService,
i18nService: I18nService,
cryptoService: CryptoService,
encryptService: EncryptService,
policyService: PolicyService,
accountService: AccountService,
stateProvider: StateProvider,
): UsernameGenerationServiceAbstraction {
const randomizer = new CryptoServiceRandomizer(cryptoService);
const effUsername = new DefaultGeneratorService(
new EffUsernameGeneratorStrategy(randomizer, stateProvider),
policyService,
);
const subaddress = new DefaultGeneratorService(
new SubaddressGeneratorStrategy(randomizer, stateProvider),
policyService,
);
const catchall = new DefaultGeneratorService(
new CatchallGeneratorStrategy(randomizer, stateProvider),
policyService,
);
const addyIo = new DefaultGeneratorService(
new AddyIoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
policyService,
);
const duckDuckGo = new DefaultGeneratorService(
new DuckDuckGoForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
policyService,
);
const fastmail = new DefaultGeneratorService(
new FastmailForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
policyService,
);
const firefoxRelay = new DefaultGeneratorService(
new FirefoxRelayForwarder(
apiService,
i18nService,
encryptService,
cryptoService,
stateProvider,
),
policyService,
);
const forwardEmail = new DefaultGeneratorService(
new ForwardEmailForwarder(
apiService,
i18nService,
encryptService,
cryptoService,
stateProvider,
),
policyService,
);
const simpleLogin = new DefaultGeneratorService(
new SimpleLoginForwarder(apiService, i18nService, encryptService, cryptoService, stateProvider),
policyService,
);
const navigation = new DefaultGeneratorNavigationService(stateProvider, policyService);
return new LegacyUsernameGenerationService(
accountService,
navigation,
catchall,
effUsername,
subaddress,
addyIo,
duckDuckGo,
fastmail,
firefoxRelay,
forwardEmail,
simpleLogin,
);
}
/** Adapts the generator 2.0 design to 1.0 angular services. */
export class LegacyUsernameGenerationService implements UsernameGenerationServiceAbstraction {
constructor(
private readonly accountService: AccountService,
private readonly navigation: GeneratorNavigationService,
private readonly catchall: GeneratorService<CatchallGenerationOptions, NoPolicy>,
private readonly effUsername: GeneratorService<EffUsernameGenerationOptions, NoPolicy>,
private readonly subaddress: GeneratorService<SubaddressGenerationOptions, NoPolicy>,
private readonly addyIo: GeneratorService<SelfHostedApiOptions & EmailDomainOptions, NoPolicy>,
private readonly duckDuckGo: GeneratorService<ApiOptions, NoPolicy>,
private readonly fastmail: GeneratorService<ApiOptions & EmailPrefixOptions, NoPolicy>,
private readonly firefoxRelay: GeneratorService<ApiOptions, NoPolicy>,
private readonly forwardEmail: GeneratorService<ApiOptions & EmailDomainOptions, NoPolicy>,
private readonly simpleLogin: GeneratorService<SelfHostedApiOptions, NoPolicy>,
) {}
generateUsername(options: UsernameGeneratorOptions) {
if (options.type === "catchall") {
return this.generateCatchall(options);
} else if (options.type === "subaddress") {
return this.generateSubaddress(options);
} else if (options.type === "forwarded") {
return this.generateForwarded(options);
} else {
return this.generateWord(options);
}
}
generateWord(options: UsernameGeneratorOptions) {
return this.effUsername.generate(options);
}
generateSubaddress(options: UsernameGeneratorOptions) {
return this.subaddress.generate(options);
}
generateCatchall(options: UsernameGeneratorOptions) {
return this.catchall.generate(options);
}
generateForwarded(options: UsernameGeneratorOptions) {
if (!options.forwardedService) {
return null;
}
const stored = this.toStoredOptions(options);
switch (options.forwardedService) {
case Forwarders.AddyIo.id:
return this.addyIo.generate(stored.forwarders.addyIo);
case Forwarders.DuckDuckGo.id:
return this.duckDuckGo.generate(stored.forwarders.duckDuckGo);
case Forwarders.Fastmail.id:
return this.fastmail.generate(stored.forwarders.fastmail);
case Forwarders.FirefoxRelay.id:
return this.firefoxRelay.generate(stored.forwarders.firefoxRelay);
case Forwarders.ForwardEmail.id:
return this.forwardEmail.generate(stored.forwarders.forwardEmail);
case Forwarders.SimpleLogin.id:
return this.simpleLogin.generate(stored.forwarders.simpleLogin);
}
}
getOptions$() {
// look upon my works, ye mighty, and despair!
const options$ = this.accountService.activeAccount$.pipe(
concatMap((account) =>
combineLatest([
this.navigation.options$(account.id),
this.navigation.defaults$(account.id),
this.catchall.options$(account.id),
this.catchall.defaults$(account.id),
this.effUsername.options$(account.id),
this.effUsername.defaults$(account.id),
this.subaddress.options$(account.id),
this.subaddress.defaults$(account.id),
this.addyIo.options$(account.id),
this.addyIo.defaults$(account.id),
this.duckDuckGo.options$(account.id),
this.duckDuckGo.defaults$(account.id),
this.fastmail.options$(account.id),
this.fastmail.defaults$(account.id),
this.firefoxRelay.options$(account.id),
this.firefoxRelay.defaults$(account.id),
this.forwardEmail.options$(account.id),
this.forwardEmail.defaults$(account.id),
this.simpleLogin.options$(account.id),
this.simpleLogin.defaults$(account.id),
]),
),
map(
([
generatorOptions,
generatorDefaults,
catchallOptions,
catchallDefaults,
effUsernameOptions,
effUsernameDefaults,
subaddressOptions,
subaddressDefaults,
addyIoOptions,
addyIoDefaults,
duckDuckGoOptions,
duckDuckGoDefaults,
fastmailOptions,
fastmailDefaults,
firefoxRelayOptions,
firefoxRelayDefaults,
forwardEmailOptions,
forwardEmailDefaults,
simpleLoginOptions,
simpleLoginDefaults,
]) =>
this.toUsernameOptions({
generator: generatorOptions ?? generatorDefaults,
algorithms: {
catchall: catchallOptions ?? catchallDefaults,
effUsername: effUsernameOptions ?? effUsernameDefaults,
subaddress: subaddressOptions ?? subaddressDefaults,
},
forwarders: {
addyIo: addyIoOptions ?? addyIoDefaults,
duckDuckGo: duckDuckGoOptions ?? duckDuckGoDefaults,
fastmail: fastmailOptions ?? fastmailDefaults,
firefoxRelay: firefoxRelayOptions ?? firefoxRelayDefaults,
forwardEmail: forwardEmailOptions ?? forwardEmailDefaults,
simpleLogin: simpleLoginOptions ?? simpleLoginDefaults,
},
}),
),
);
return options$;
}
getOptions() {
return firstValueFrom(this.getOptions$());
}
async saveOptions(options: UsernameGeneratorOptions) {
const stored = this.toStoredOptions(options);
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
// generator settings needs to preserve whether password or passphrase is selected,
// so `navigationOptions` is mutated.
const navigationOptions$ = zip(
this.navigation.options$(activeAccount.id),
this.navigation.defaults$(activeAccount.id),
).pipe(map(([options, defaults]) => options ?? defaults));
let navigationOptions = await firstValueFrom(navigationOptions$);
navigationOptions = Object.assign(navigationOptions, stored.generator);
await this.navigation.saveOptions(activeAccount.id, navigationOptions);
// overwrite all other settings with latest values
await Promise.all([
this.catchall.saveOptions(activeAccount.id, stored.algorithms.catchall),
this.effUsername.saveOptions(activeAccount.id, stored.algorithms.effUsername),
this.subaddress.saveOptions(activeAccount.id, stored.algorithms.subaddress),
this.addyIo.saveOptions(activeAccount.id, stored.forwarders.addyIo),
this.duckDuckGo.saveOptions(activeAccount.id, stored.forwarders.duckDuckGo),
this.fastmail.saveOptions(activeAccount.id, stored.forwarders.fastmail),
this.firefoxRelay.saveOptions(activeAccount.id, stored.forwarders.firefoxRelay),
this.forwardEmail.saveOptions(activeAccount.id, stored.forwarders.forwardEmail),
this.simpleLogin.saveOptions(activeAccount.id, stored.forwarders.simpleLogin),
]);
}
private toStoredOptions(options: UsernameGeneratorOptions) {
const forwarders = {
addyIo: {
baseUrl: options.forwardedAnonAddyBaseUrl,
token: options.forwardedAnonAddyApiToken,
domain: options.forwardedAnonAddyDomain,
website: options.website,
},
duckDuckGo: {
token: options.forwardedDuckDuckGoToken,
website: options.website,
},
fastmail: {
token: options.forwardedFastmailApiToken,
website: options.website,
},
firefoxRelay: {
token: options.forwardedFirefoxApiToken,
website: options.website,
},
forwardEmail: {
token: options.forwardedForwardEmailApiToken,
domain: options.forwardedForwardEmailDomain,
website: options.website,
},
simpleLogin: {
token: options.forwardedSimpleLoginApiKey,
baseUrl: options.forwardedSimpleLoginBaseUrl,
website: options.website,
},
};
const generator = {
username: options.type,
forwarder: options.forwardedService,
};
const algorithms = {
effUsername: {
wordCapitalize: options.wordCapitalize,
wordIncludeNumber: options.wordIncludeNumber,
website: options.website,
},
subaddress: {
subaddressType: options.subaddressType,
subaddressEmail: options.subaddressEmail,
website: options.website,
},
catchall: {
catchallType: options.catchallType,
catchallDomain: options.catchallDomain,
website: options.website,
},
};
return { generator, algorithms, forwarders } as MappedOptions;
}
private toUsernameOptions(options: MappedOptions) {
return {
type: options.generator.username,
wordCapitalize: options.algorithms.effUsername.wordCapitalize,
wordIncludeNumber: options.algorithms.effUsername.wordIncludeNumber,
subaddressType: options.algorithms.subaddress.subaddressType,
subaddressEmail: options.algorithms.subaddress.subaddressEmail,
catchallType: options.algorithms.catchall.catchallType,
catchallDomain: options.algorithms.catchall.catchallDomain,
forwardedService: options.generator.forwarder,
forwardedAnonAddyApiToken: options.forwarders.addyIo.token,
forwardedAnonAddyDomain: options.forwarders.addyIo.domain,
forwardedAnonAddyBaseUrl: options.forwarders.addyIo.baseUrl,
forwardedDuckDuckGoToken: options.forwarders.duckDuckGo.token,
forwardedFirefoxApiToken: options.forwarders.firefoxRelay.token,
forwardedFastmailApiToken: options.forwarders.fastmail.token,
forwardedForwardEmailApiToken: options.forwarders.forwardEmail.token,
forwardedForwardEmailDomain: options.forwarders.forwardEmail.domain,
forwardedSimpleLoginApiKey: options.forwarders.simpleLogin.token,
forwardedSimpleLoginBaseUrl: options.forwarders.simpleLogin.baseUrl,
} as UsernameGeneratorOptions;
}
}

View File

@ -1,100 +0,0 @@
/**
* include structuredClone in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { UserId } from "../../../types/guid";
import { GENERATOR_SETTINGS } from "../key-definitions";
import {
GeneratorNavigationEvaluator,
DefaultGeneratorNavigationService,
DefaultGeneratorNavigation,
} from "./";
const SomeUser = "some user" as UserId;
describe("DefaultGeneratorNavigationService", () => {
describe("options$", () => {
it("emits options", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const settings = { type: "password" as const };
await stateProvider.setUserState(GENERATOR_SETTINGS, settings, SomeUser);
const navigation = new DefaultGeneratorNavigationService(stateProvider, null);
const result = await firstValueFrom(navigation.options$(SomeUser));
expect(result).toEqual(settings);
});
});
describe("defaults$", () => {
it("emits default options", async () => {
const navigation = new DefaultGeneratorNavigationService(null, null);
const result = await firstValueFrom(navigation.defaults$(SomeUser));
expect(result).toEqual(DefaultGeneratorNavigation);
});
});
describe("evaluator$", () => {
it("emits a GeneratorNavigationEvaluator", async () => {
const policyService = mock<PolicyService>({
getAll$() {
return of([]);
},
});
const navigation = new DefaultGeneratorNavigationService(null, policyService);
const result = await firstValueFrom(navigation.evaluator$(SomeUser));
expect(result).toBeInstanceOf(GeneratorNavigationEvaluator);
});
});
describe("enforcePolicy", () => {
it("applies policy", async () => {
const policyService = mock<PolicyService>({
getAll$(_type: PolicyType, _user: UserId) {
return of([
new Policy({
id: "" as any,
organizationId: "" as any,
enabled: true,
type: PolicyType.PasswordGenerator,
data: { defaultType: "password" },
}),
]);
},
});
const navigation = new DefaultGeneratorNavigationService(null, policyService);
const options = {};
const result = await navigation.enforcePolicy(SomeUser, options);
expect(result).toMatchObject({ type: "password" });
});
});
describe("saveOptions", () => {
it("updates options$", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const navigation = new DefaultGeneratorNavigationService(stateProvider, null);
const settings = { type: "password" as const };
await navigation.saveOptions(SomeUser, settings);
const result = await firstValueFrom(navigation.options$(SomeUser));
expect(result).toEqual(settings);
});
});
});

View File

@ -1,72 +0,0 @@
import { BehaviorSubject, Observable, firstValueFrom, map } from "rxjs";
import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction";
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 { DefaultGeneratorNavigation, GeneratorNavigation } from "./generator-navigation";
import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator";
import { DisabledGeneratorNavigationPolicy, preferPassword } from "./generator-navigation-policy";
export class DefaultGeneratorNavigationService implements GeneratorNavigationService {
/** instantiates the password generator strategy.
* @param stateProvider provides durable state
* @param policy provides the policy to enforce
*/
constructor(
private readonly stateProvider: StateProvider,
private readonly policy: PolicyService,
) {}
/** 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<GeneratorNavigation> {
return this.stateProvider.getUserState$(GENERATOR_SETTINGS, userId);
}
/** Gets the default options. */
defaults$(userId: UserId): Observable<GeneratorNavigation> {
return new BehaviorSubject({ ...DefaultGeneratorNavigation });
}
/** 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) {
const evaluator$ = this.policy.getAll$(PolicyType.PasswordGenerator, userId).pipe(
reduceCollection(preferPassword, DisabledGeneratorNavigationPolicy),
distinctIfShallowMatch(),
map((policy) => new GeneratorNavigationEvaluator(policy)),
);
return evaluator$;
}
/** 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
*/
async enforcePolicy(userId: UserId, options: GeneratorNavigation) {
const evaluator = await firstValueFrom(this.evaluator$(userId));
const applied = evaluator.applyPolicy(options);
const sanitized = evaluator.sanitize(applied);
return sanitized;
}
/** Saves the navigation 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
*/
async saveOptions(userId: UserId, options: GeneratorNavigation): Promise<void> {
await this.stateProvider.setUserState(GENERATOR_SETTINGS, options, userId);
}
}

View File

@ -1,64 +0,0 @@
import { DefaultGeneratorNavigation } from "./generator-navigation";
import { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator";
describe("GeneratorNavigationEvaluator", () => {
describe("policyInEffect", () => {
it.each([["passphrase"], ["password"]] as const)(
"returns true if the policy has a defaultType (= %p)",
(defaultType) => {
const evaluator = new GeneratorNavigationEvaluator({ defaultType });
expect(evaluator.policyInEffect).toEqual(true);
},
);
it.each([[undefined], [null], ["" as any]])(
"returns false if the policy has a falsy defaultType (= %p)",
(defaultType) => {
const evaluator = new GeneratorNavigationEvaluator({ defaultType });
expect(evaluator.policyInEffect).toEqual(false);
},
);
});
describe("applyPolicy", () => {
it("returns the input options", () => {
const evaluator = new GeneratorNavigationEvaluator(null);
const options = { type: "password" as const };
const result = evaluator.applyPolicy(options);
expect(result).toEqual(options);
});
});
describe("sanitize", () => {
it.each([["passphrase"], ["password"]] as const)(
"defaults options to the policy's default type (= %p) when a policy is in effect",
(defaultType) => {
const evaluator = new GeneratorNavigationEvaluator({ defaultType });
const result = evaluator.sanitize({});
expect(result).toEqual({ type: defaultType });
},
);
it("defaults options to the default generator navigation type when a policy is not in effect", () => {
const evaluator = new GeneratorNavigationEvaluator(null);
const result = evaluator.sanitize({});
expect(result.type).toEqual(DefaultGeneratorNavigation.type);
});
it("retains the options type when it is set", () => {
const evaluator = new GeneratorNavigationEvaluator({ defaultType: "passphrase" });
const result = evaluator.sanitize({ type: "password" });
expect(result).toEqual({ type: "password" });
});
});
});

View File

@ -1,43 +0,0 @@
import { PolicyEvaluator } from "../abstractions";
import { DefaultGeneratorNavigation, GeneratorNavigation } from "./generator-navigation";
import { GeneratorNavigationPolicy } from "./generator-navigation-policy";
/** Enforces policy for generator navigation options.
*/
export class GeneratorNavigationEvaluator
implements PolicyEvaluator<GeneratorNavigationPolicy, GeneratorNavigation>
{
/** Instantiates the evaluator.
* @param policy The policy applied by the evaluator. When this conflicts with
* the defaults, the policy takes precedence.
*/
constructor(readonly policy: GeneratorNavigationPolicy) {}
/** {@link PolicyEvaluator.policyInEffect} */
get policyInEffect(): boolean {
return this.policy?.defaultType ? true : false;
}
/** 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: GeneratorNavigation): GeneratorNavigation {
return options;
}
/** 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: GeneratorNavigation): GeneratorNavigation {
const defaultType = this.policyInEffect
? this.policy.defaultType
: DefaultGeneratorNavigation.type;
return {
...options,
type: options.type ?? defaultType,
};
}
}

View File

@ -1,63 +0,0 @@
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { PolicyId } from "../../../types/guid";
import { DisabledGeneratorNavigationPolicy, preferPassword } from "./generator-navigation-policy";
function createPolicy(
data: any,
type: PolicyType = PolicyType.PasswordGenerator,
enabled: boolean = true,
) {
return new Policy({
id: "id" as PolicyId,
organizationId: "organizationId",
data,
enabled,
type,
});
}
describe("leastPrivilege", () => {
it("should return the accumulator when the policy type does not apply", () => {
const policy = createPolicy({}, PolicyType.RequireSso);
const result = preferPassword(DisabledGeneratorNavigationPolicy, policy);
expect(result).toEqual(DisabledGeneratorNavigationPolicy);
});
it("should return the accumulator when the policy is not enabled", () => {
const policy = createPolicy({}, PolicyType.PasswordGenerator, false);
const result = preferPassword(DisabledGeneratorNavigationPolicy, policy);
expect(result).toEqual(DisabledGeneratorNavigationPolicy);
});
it("should take the %p from the policy", () => {
const policy = createPolicy({ defaultType: "passphrase" });
const result = preferPassword({ ...DisabledGeneratorNavigationPolicy }, policy);
expect(result).toEqual({ defaultType: "passphrase" });
});
it("should override passphrase with password", () => {
const policy = createPolicy({ defaultType: "password" });
const result = preferPassword({ defaultType: "passphrase" }, policy);
expect(result).toEqual({ defaultType: "password" });
});
it("should not override password", () => {
const policy = createPolicy({ defaultType: "passphrase" });
const result = preferPassword({ defaultType: "password" }, policy);
expect(result).toEqual({ defaultType: "password" });
});
});

View File

@ -1,39 +0,0 @@
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { GeneratorType } from "../generator-type";
/** Policy settings affecting password generator navigation */
export type GeneratorNavigationPolicy = {
/** The type of generator that should be shown by default when opening
* the password generator.
*/
defaultType?: GeneratorType;
};
/** Reduces a policy into an accumulator by preferring the password generator
* type to other generator types.
* @param acc the accumulator
* @param policy the policy to reduce
* @returns the resulting `GeneratorNavigationPolicy`
*/
export function preferPassword(
acc: GeneratorNavigationPolicy,
policy: Policy,
): GeneratorNavigationPolicy {
const isEnabled = policy.type === PolicyType.PasswordGenerator && policy.enabled;
if (!isEnabled) {
return acc;
}
const isOverridable = acc.defaultType !== "password" && policy.data.defaultType;
const result = isOverridable ? { ...acc, defaultType: policy.data.defaultType } : acc;
return result;
}
/** The default options for password generation policy. */
export const DisabledGeneratorNavigationPolicy: GeneratorNavigationPolicy = Object.freeze({
defaultType: undefined,
});

View File

@ -1,26 +0,0 @@
import { GeneratorType } from "../generator-type";
import { ForwarderId } from "../username/options";
import { UsernameGeneratorType } from "../username/options/generator-options";
/** Stores credential generator UI state. */
export type GeneratorNavigation = {
/** The kind of credential being generated.
* @remarks The legacy generator only supports "password" and "passphrase".
* The componentized generator supports all values.
*/
type?: GeneratorType;
/** When `type === "username"`, this stores the username algorithm. */
username?: UsernameGeneratorType;
/** When `username === "forwarded"`, this stores the forwarder implementation. */
forwarder?: ForwarderId | "";
};
/** The default options for password generation. */
export const DefaultGeneratorNavigation: Partial<GeneratorNavigation> = Object.freeze({
type: "password",
username: "word",
forwarder: "",
});

View File

@ -1,3 +0,0 @@
export { GeneratorNavigationEvaluator } from "./generator-navigation-evaluator";
export { DefaultGeneratorNavigationService } from "./default-generator-navigation.service";
export { GeneratorNavigation, DefaultGeneratorNavigation } from "./generator-navigation";

View File

@ -1,2 +0,0 @@
/** Type representing an absence of policy. */
export type NoPolicy = Record<string, never>;

View File

@ -1,8 +0,0 @@
// password generator "v2" interfaces
export { PassphraseGeneratorOptionsEvaluator } from "./passphrase-generator-options-evaluator";
export { PassphraseGeneratorPolicy } from "./passphrase-generator-policy";
export { PassphraseGeneratorStrategy } from "./passphrase-generator-strategy";
export {
DefaultPassphraseGenerationOptions,
PassphraseGenerationOptions,
} from "./passphrase-generation-options";

View File

@ -1,35 +0,0 @@
/** 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;
};
/** The default options for passphrase generation. */
export const DefaultPassphraseGenerationOptions: Partial<PassphraseGenerationOptions> =
Object.freeze({
numWords: 3,
wordSeparator: "-",
capitalize: false,
includeNumber: false,
});

View File

@ -1,266 +0,0 @@
/**
* include structuredClone in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { PassphraseGenerationOptions } from "./passphrase-generation-options";
import {
DefaultBoundaries,
PassphraseGeneratorOptionsEvaluator,
} from "./passphrase-generator-options-evaluator";
import { DisabledPassphraseGeneratorPolicy } from "./passphrase-generator-policy";
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(DefaultBoundaries.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(DefaultBoundaries.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(DefaultBoundaries.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 = DefaultBoundaries.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(DefaultBoundaries.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(DefaultBoundaries.numWords.min);
expect(numWords).toBeLessThanOrEqual(DefaultBoundaries.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(DefaultBoundaries.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");
});
});
});

View File

@ -1,125 +0,0 @@
import { PolicyEvaluator } from "../abstractions/policy-evaluator.abstraction";
import {
DefaultPassphraseGenerationOptions,
PassphraseGenerationOptions,
} from "./passphrase-generation-options";
import { PassphraseGeneratorPolicy } from "./passphrase-generator-policy";
type Boundary = {
readonly min: number;
readonly max: number;
};
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 DefaultBoundaries = initializeBoundaries();
/** 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, DefaultBoundaries.numWords);
}
/** {@link PolicyEvaluator.policyInEffect} */
get policyInEffect(): boolean {
const policies = [
this.policy.capitalize,
this.policy.includeNumber,
this.policy.minNumberWords > DefaultBoundaries.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,
};
}
}

View File

@ -1,51 +0,0 @@
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { PolicyId } from "../../../types/guid";
import { DisabledPassphraseGeneratorPolicy, leastPrivilege } from "./passphrase-generator-policy";
function createPolicy(
data: any,
type: PolicyType = PolicyType.PasswordGenerator,
enabled: boolean = true,
) {
return new Policy({
id: "id" as PolicyId,
organizationId: "organizationId",
data,
enabled,
type,
});
}
describe("leastPrivilege", () => {
it("should return the accumulator when the policy type does not apply", () => {
const policy = createPolicy({}, PolicyType.RequireSso);
const result = leastPrivilege(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 = leastPrivilege(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 = leastPrivilege(DisabledPassphraseGeneratorPolicy, policy);
expect(result).toEqual({ ...DisabledPassphraseGeneratorPolicy, [input]: value });
});
});

View File

@ -1,39 +0,0 @@
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
/** Policy options enforced during passphrase generation. */
export type PassphraseGeneratorPolicy = {
minNumberWords: number;
capitalize: boolean;
includeNumber: boolean;
};
/** The default options for password generation policy. */
export const DisabledPassphraseGeneratorPolicy: PassphraseGeneratorPolicy = Object.freeze({
minNumberWords: 0,
capitalize: false,
includeNumber: false,
});
/** 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 leastPrivilege(
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,
};
}

View File

@ -1,99 +0,0 @@
/**
* include structuredClone in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { Randomizer } from "../abstractions/randomizer";
import { PASSPHRASE_SETTINGS } from "../key-definitions";
import { DisabledPassphraseGeneratorPolicy } from "./passphrase-generator-policy";
import {
DefaultPassphraseGenerationOptions,
PassphraseGeneratorOptionsEvaluator,
PassphraseGeneratorStrategy,
} from ".";
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");
});
});

View File

@ -1,70 +0,0 @@
import { GeneratorStrategy } from "..";
import { PolicyType } from "../../../admin-console/enums";
import { EFFLongWordList } from "../../../platform/misc/wordlist";
import { StateProvider } from "../../../platform/state";
import { Randomizer } from "../abstractions/randomizer";
import { PASSPHRASE_SETTINGS } from "../key-definitions";
import { Policies } from "../policies";
import { mapPolicyToEvaluator } from "../rx-operators";
import { clone$PerUserId, sharedStateByUserId } from "../util";
import {
PassphraseGenerationOptions,
DefaultPassphraseGenerationOptions,
} from "./passphrase-generation-options";
import { PassphraseGeneratorPolicy } from "./passphrase-generator-policy";
/** 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 - 1);
}
// 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);
}
}

View File

@ -1,9 +0,0 @@
export class GeneratedPasswordHistory {
password: string;
date: number;
constructor(password: string, date: number) {
this.password = password;
this.date = date;
}
}

View File

@ -1,10 +0,0 @@
// password generator "v2" interfaces
export * from "./password-generation-options";
export { PasswordGeneratorOptionsEvaluator } from "./password-generator-options-evaluator";
export { PasswordGeneratorPolicy } from "./password-generator-policy";
export { PasswordGeneratorStrategy } from "./password-generator-strategy";
// legacy interfaces
export { PasswordGeneratorOptions } from "./password-generator-options";
export { PasswordGenerationServiceAbstraction } from "../abstractions/password-generation.service.abstraction";
export { GeneratedPasswordHistory } from "./generated-password-history";

View File

@ -1,83 +0,0 @@
import { DefaultBoundaries } from "./password-generator-options-evaluator";
/** 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;
};
/** The default options for password generation. */
export const DefaultPasswordGenerationOptions: Partial<PasswordGenerationOptions> = Object.freeze({
length: 14,
minLength: DefaultBoundaries.length.min,
ambiguous: true,
uppercase: true,
lowercase: true,
number: true,
minNumber: 1,
special: false,
minSpecial: 0,
});

View File

@ -1,770 +0,0 @@
/**
* include structuredClone in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { DefaultBoundaries } from "./password-generator-options-evaluator";
import { DisabledPasswordGeneratorPolicy } from "./password-generator-policy";
import { PasswordGenerationOptions, PasswordGeneratorOptionsEvaluator } from ".";
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(DefaultBoundaries.length);
expect(builder.minDigits).toEqual(DefaultBoundaries.minDigits);
expect(builder.minSpecialCharacters).toEqual(DefaultBoundaries.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(DefaultBoundaries.length.min);
const policy = Object.assign({}, DisabledPasswordGeneratorPolicy);
policy.minLength = minLength;
const builder = new PasswordGeneratorOptionsEvaluator(policy);
expect(builder.length).toEqual(DefaultBoundaries.length);
},
);
it.each([8, 20, 100])(
"should use `policy.minLength` (= %i) when it is greater than the default minimum length",
(expectedLength) => {
expect(expectedLength).toBeGreaterThan(DefaultBoundaries.length.min);
expect(expectedLength).toBeLessThanOrEqual(DefaultBoundaries.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(DefaultBoundaries.length.max);
},
);
it.each([150, 300, 9000])(
"should use `policy.minLength` (= %i) when it is greater than the default boundaries",
(expectedLength) => {
expect(expectedLength).toBeGreaterThan(DefaultBoundaries.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(DefaultBoundaries.minDigits.min);
expect(expectedMinDigits).toBeLessThanOrEqual(DefaultBoundaries.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(DefaultBoundaries.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(DefaultBoundaries.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(
DefaultBoundaries.minSpecialCharacters.min,
);
expect(expectedSpecialCharacters).toBeLessThanOrEqual(
DefaultBoundaries.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(
DefaultBoundaries.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(
DefaultBoundaries.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(DefaultBoundaries.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 = DefaultBoundaries.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 = DefaultBoundaries.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 = DefaultBoundaries.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(DefaultBoundaries.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(DefaultBoundaries.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");
});
});
});

View File

@ -1,186 +0,0 @@
import { PolicyEvaluator } from "../abstractions/policy-evaluator.abstraction";
import { PasswordGenerationOptions } from "./password-generation-options";
import { PasswordGeneratorPolicy } from "./password-generator-policy";
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 DefaultBoundaries = initializeBoundaries();
type Boundary = {
readonly min: number;
readonly max: number;
};
/** 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, DefaultBoundaries.minDigits);
this.minSpecialCharacters = createBoundary(
policy.specialCount,
DefaultBoundaries.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 : DefaultBoundaries.length.min;
const minLength = Math.max(minPolicyLength, minConsistentLength, DefaultBoundaries.length.min);
this.length = {
min: minLength,
max: Math.max(DefaultBoundaries.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 > DefaultBoundaries.length.min,
this.policy.numberCount > DefaultBoundaries.minDigits.min,
this.policy.specialCount > DefaultBoundaries.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,
};
}
}

View File

@ -1,11 +0,0 @@
import { GeneratorNavigation } from "../navigation/generator-navigation";
import { PassphraseGenerationOptions } from "../passphrase/passphrase-generation-options";
import { PasswordGenerationOptions } from "./password-generation-options";
/** Request format for credential generation.
* This type includes all properties suitable for reactive data binding.
*/
export type PasswordGeneratorOptions = PasswordGenerationOptions &
PassphraseGenerationOptions &
GeneratorNavigation & { policyUpdated?: boolean };

View File

@ -1,55 +0,0 @@
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { PolicyId } from "../../../types/guid";
import { DisabledPasswordGeneratorPolicy, leastPrivilege } from "./password-generator-policy";
function createPolicy(
data: any,
type: PolicyType = PolicyType.PasswordGenerator,
enabled: boolean = true,
) {
return new Policy({
id: "id" as PolicyId,
organizationId: "organizationId",
data,
enabled,
type,
});
}
describe("leastPrivilege", () => {
it("should return the accumulator when the policy type does not apply", () => {
const policy = createPolicy({}, PolicyType.RequireSso);
const result = leastPrivilege(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 = leastPrivilege(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 = leastPrivilege(DisabledPasswordGeneratorPolicy, policy);
expect(result).toEqual({ ...DisabledPasswordGeneratorPolicy, [expected]: value });
});
});

View File

@ -1,77 +0,0 @@
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
/** 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;
};
/** 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,
});
/** 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 leastPrivilege(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),
};
}

View File

@ -1,108 +0,0 @@
/**
* include structuredClone in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { Randomizer } from "../abstractions/randomizer";
import { PASSWORD_SETTINGS } from "../key-definitions";
import { DisabledPasswordGeneratorPolicy } from "./password-generator-policy";
import {
DefaultPasswordGenerationOptions,
PasswordGeneratorOptionsEvaluator,
PasswordGeneratorStrategy,
} from ".";
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");
});
});

View File

@ -1,128 +0,0 @@
import { GeneratorStrategy } from "..";
import { PolicyType } from "../../../admin-console/enums";
import { StateProvider } from "../../../platform/state";
import { Randomizer } from "../abstractions/randomizer";
import { PASSWORD_SETTINGS } from "../key-definitions";
import { Policies } from "../policies";
import { mapPolicyToEvaluator } from "../rx-operators";
import { clone$PerUserId, sharedStateByUserId } from "../util";
import {
DefaultPasswordGenerationOptions,
PasswordGenerationOptions,
} from "./password-generation-options";
import { PasswordGeneratorPolicy } from "./password-generator-policy";
/** 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;
}
}

View File

@ -1,48 +0,0 @@
import { Policy as AdminPolicy } from "@bitwarden/common/admin-console/models/domain/policy";
import { PassphraseGeneratorOptionsEvaluator, PassphraseGeneratorPolicy } from "./passphrase";
import {
DisabledPassphraseGeneratorPolicy,
leastPrivilege as passphraseLeastPrivilege,
} from "./passphrase/passphrase-generator-policy";
import { PasswordGeneratorOptionsEvaluator, PasswordGeneratorPolicy } from "./password";
import {
DisabledPasswordGeneratorPolicy,
leastPrivilege as passwordLeastPrivilege,
} from "./password/password-generator-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;
};
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,
});

View File

@ -1,62 +0,0 @@
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { Randomizer } from "./abstractions/randomizer";
import { WordOptions } from "./word-options";
/** 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, 9);
word = word + num.toString();
}
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 = shuffled.length - 1; i > 0; i--) {
const j = await this.uniform(0, i);
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[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;
}
}

View File

@ -1,26 +0,0 @@
import { map, pipe } from "rxjs";
import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx";
import { DefaultPolicyEvaluator } from "./default-policy-evaluator";
import { PolicyConfiguration } from "./policies";
/** 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>()));
};
}

View File

@ -1,21 +0,0 @@
import { RequestOptions } from "./options/forwarder-options";
import { UsernameGenerationMode } from "./options/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;
/** The default options for catchall address generation. */
export const DefaultCatchallOptions: CatchallGenerationOptions = Object.freeze({
catchallType: "random",
catchallDomain: "",
website: null,
});

View File

@ -1,75 +0,0 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { Randomizer } from "../abstractions/randomizer";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { CATCHALL_SETTINGS } from "../key-definitions";
import { DefaultCatchallOptions } from "./catchall-generator-options";
import { CatchallGeneratorStrategy } from ".";
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");
});
});

View File

@ -1,50 +0,0 @@
import { PolicyType } from "../../../admin-console/enums";
import { StateProvider } from "../../../platform/state";
import { GeneratorStrategy } from "../abstractions";
import { Randomizer } from "../abstractions/randomizer";
import { CATCHALL_SETTINGS } from "../key-definitions";
import { NoPolicy } from "../no-policy";
import { newDefaultEvaluator } from "../rx-operators";
import { clone$PerUserId, sharedStateByUserId } from "../util";
import { CatchallGenerationOptions, DefaultCatchallOptions } from "./catchall-generator-options";
/** 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;
}
}

View File

@ -1,17 +0,0 @@
import { RequestOptions } from "./options/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;
/** The default options for EFF long word generation. */
export const DefaultEffUsernameOptions: EffUsernameGenerationOptions = Object.freeze({
wordCapitalize: false,
wordIncludeNumber: false,
website: null,
});

View File

@ -1,75 +0,0 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { Randomizer } from "../abstractions/randomizer";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { EFF_USERNAME_SETTINGS } from "../key-definitions";
import { DefaultEffUsernameOptions } from "./eff-username-generator-options";
import { EffUsernameGeneratorStrategy } from ".";
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");
});
});

View File

@ -1,44 +0,0 @@
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist";
import { PolicyType } from "../../../admin-console/enums";
import { StateProvider } from "../../../platform/state";
import { GeneratorStrategy } from "../abstractions";
import { Randomizer } from "../abstractions/randomizer";
import { EFF_USERNAME_SETTINGS } from "../key-definitions";
import { NoPolicy } from "../no-policy";
import { newDefaultEvaluator } from "../rx-operators";
import { clone$PerUserId, sharedStateByUserId } from "../util";
import {
DefaultEffUsernameOptions,
EffUsernameGenerationOptions,
} from "./eff-username-generator-options";
/** 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;
}
}

View File

@ -1,49 +0,0 @@
import { ApiService } from "../../../../abstractions/api.service";
import { Forwarder } from "./forwarder";
import { ForwarderOptions } from "./forwarder-options";
export class AnonAddyForwarder implements Forwarder {
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
if (options.apiKey == null || options.apiKey === "") {
throw "Invalid addy.io API token.";
}
if (options.anonaddy?.domain == null || options.anonaddy.domain === "") {
throw "Invalid addy.io domain.";
}
if (options.anonaddy?.baseUrl == null || options.anonaddy.baseUrl === "") {
throw "Invalid addy.io url.";
}
const requestInit: RequestInit = {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Bearer " + options.apiKey,
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
}),
};
const url = options.anonaddy.baseUrl + "/api/v1/aliases";
requestInit.body = JSON.stringify({
domain: options.anonaddy.domain,
description:
(options.website != null ? "Website: " + options.website + ". " : "") +
"Generated by Bitwarden.",
});
const request = new Request(url, requestInit);
const response = await apiService.nativeFetch(request);
if (response.status === 200 || response.status === 201) {
const json = await response.json();
return json?.data?.email;
}
if (response.status === 401) {
throw "Invalid addy.io API token.";
}
if (response?.statusText != null) {
throw "addy.io error:\n" + response.statusText;
}
throw "Unknown addy.io error occurred.";
}
}

View File

@ -1,33 +0,0 @@
import { ApiService } from "../../../../abstractions/api.service";
import { Forwarder } from "./forwarder";
import { ForwarderOptions } from "./forwarder-options";
export class DuckDuckGoForwarder implements Forwarder {
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
if (options.apiKey == null || options.apiKey === "") {
throw "Invalid DuckDuckGo API token.";
}
const requestInit: RequestInit = {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Bearer " + options.apiKey,
"Content-Type": "application/json",
}),
};
const url = "https://quack.duckduckgo.com/api/email/addresses";
const request = new Request(url, requestInit);
const response = await apiService.nativeFetch(request);
if (response.status === 200 || response.status === 201) {
const json = await response.json();
if (json.address) {
return `${json.address}@duck.com`;
}
} else if (response.status === 401) {
throw "Invalid DuckDuckGo API token.";
}
throw "Unknown DuckDuckGo error occurred.";
}
}

View File

@ -1,98 +0,0 @@
import { ApiService } from "../../../../abstractions/api.service";
import { Forwarder } from "./forwarder";
import { ForwarderOptions } from "./forwarder-options";
export class FastmailForwarder implements Forwarder {
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
if (options.apiKey == null || options.apiKey === "") {
throw "Invalid Fastmail API token.";
}
const accountId = await this.getAccountId(apiService, options);
if (accountId == null || accountId === "") {
throw "Unable to obtain Fastmail masked email account ID.";
}
const forDomain = options.website || "";
const requestInit: RequestInit = {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Bearer " + options.apiKey,
"Content-Type": "application/json",
}),
};
const url = "https://api.fastmail.com/jmap/api/";
requestInit.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: forDomain,
emailPrefix: options.fastmail.prefix,
},
},
},
"0",
],
],
});
const request = new Request(url, requestInit);
const response = await 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) {
throw (
"Fastmail error: " +
json.methodResponses[0][1]?.notCreated?.["new-masked-email"]?.description
);
}
} else if (json.methodResponses[0][0] === "error") {
throw "Fastmail error: " + json.methodResponses[0][1]?.description;
}
}
}
if (response.status === 401 || response.status === 403) {
throw "Invalid Fastmail API token.";
}
throw "Unknown Fastmail error occurred.";
}
private async getAccountId(apiService: ApiService, options: ForwarderOptions): Promise<string> {
const requestInit: RequestInit = {
cache: "no-store",
method: "GET",
headers: new Headers({
Authorization: "Bearer " + options.apiKey,
}),
};
const url = "https://api.fastmail.com/.well-known/jmap";
const request = new Request(url, requestInit);
const response = await 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;
}
}

View File

@ -1,38 +0,0 @@
import { ApiService } from "../../../../abstractions/api.service";
import { Forwarder } from "./forwarder";
import { ForwarderOptions } from "./forwarder-options";
export class FirefoxRelayForwarder implements Forwarder {
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
if (options.apiKey == null || options.apiKey === "") {
throw "Invalid Firefox Relay API token.";
}
const requestInit: RequestInit = {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Token " + options.apiKey,
"Content-Type": "application/json",
}),
};
const url = "https://relay.firefox.com/api/v1/relayaddresses/";
requestInit.body = JSON.stringify({
enabled: true,
generated_for: options.website,
description:
(options.website != null ? options.website + " - " : "") + "Generated by Bitwarden.",
});
const request = new Request(url, requestInit);
const response = await apiService.nativeFetch(request);
if (response.status === 200 || response.status === 201) {
const json = await response.json();
return json?.full_address;
}
if (response.status === 401) {
throw "Invalid Firefox Relay API token.";
}
throw "Unknown Firefox Relay error occurred.";
}
}

View File

@ -1,49 +0,0 @@
import { ApiService } from "../../../../abstractions/api.service";
import { Utils } from "../../../../platform/misc/utils";
import { Forwarder } from "./forwarder";
import { ForwarderOptions } from "./forwarder-options";
export class ForwardEmailForwarder implements Forwarder {
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
if (options.apiKey == null || options.apiKey === "") {
throw "Invalid Forward Email API key.";
}
if (options.forwardemail?.domain == null || options.forwardemail.domain === "") {
throw "Invalid Forward Email domain.";
}
const requestInit: RequestInit = {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authorization: "Basic " + Utils.fromUtf8ToB64(options.apiKey + ":"),
"Content-Type": "application/json",
}),
};
const url = `https://api.forwardemail.net/v1/domains/${options.forwardemail.domain}/aliases`;
requestInit.body = JSON.stringify({
labels: options.website,
description:
(options.website != null ? "Website: " + options.website + ". " : "") +
"Generated by Bitwarden.",
});
const request = new Request(url, requestInit);
const response = await apiService.nativeFetch(request);
if (response.status === 200 || response.status === 201) {
const json = await response.json();
return json?.name + "@" + (json?.domain?.name || options.forwardemail.domain);
}
if (response.status === 401) {
throw "Invalid Forward Email API key.";
}
const json = await response.json();
if (json?.message != null) {
throw "Forward Email error:\n" + json.message;
}
if (json?.error != null) {
throw "Forward Email error:\n" + json.error;
}
throw "Unknown Forward Email error occurred.";
}
}

View File

@ -1,25 +0,0 @@
export class ForwarderOptions {
apiKey: string;
website: string;
fastmail = new FastmailForwarderOptions();
anonaddy = new AnonAddyForwarderOptions();
forwardemail = new ForwardEmailForwarderOptions();
simplelogin = new SimpleLoginForwarderOptions();
}
export class FastmailForwarderOptions {
prefix: string;
}
export class AnonAddyForwarderOptions {
domain: string;
baseUrl: string;
}
export class ForwardEmailForwarderOptions {
domain: string;
}
export class SimpleLoginForwarderOptions {
baseUrl: string;
}

View File

@ -1,7 +0,0 @@
import { ApiService } from "../../../../abstractions/api.service";
import { ForwarderOptions } from "./forwarder-options";
export interface Forwarder {
generate(apiService: ApiService, options: ForwarderOptions): Promise<string>;
}

View File

@ -1,8 +0,0 @@
export { AnonAddyForwarder } from "./anon-addy-forwarder";
export { DuckDuckGoForwarder } from "./duck-duck-go-forwarder";
export { FastmailForwarder } from "./fastmail-forwarder";
export { FirefoxRelayForwarder } from "./firefox-relay-forwarder";
export { Forwarder } from "./forwarder";
export { ForwarderOptions } from "./forwarder-options";
export { SimpleLoginForwarder } from "./simple-login-forwarder";
export { ForwardEmailForwarder } from "./forward-email-forwarder";

View File

@ -1,44 +0,0 @@
import { ApiService } from "../../../../abstractions/api.service";
import { Forwarder } from "./forwarder";
import { ForwarderOptions } from "./forwarder-options";
export class SimpleLoginForwarder implements Forwarder {
async generate(apiService: ApiService, options: ForwarderOptions): Promise<string> {
if (options.apiKey == null || options.apiKey === "") {
throw "Invalid SimpleLogin API key.";
}
const requestInit: RequestInit = {
redirect: "manual",
cache: "no-store",
method: "POST",
headers: new Headers({
Authentication: options.apiKey,
"Content-Type": "application/json",
}),
};
let url = options.simplelogin.baseUrl + "/api/alias/random/new";
if (options.website != null) {
url += "?hostname=" + options.website;
}
requestInit.body = JSON.stringify({
note:
(options.website != null ? "Website: " + options.website + ". " : "") +
"Generated by Bitwarden.",
});
const request = new Request(url, requestInit);
const response = await apiService.nativeFetch(request);
if (response.status === 200 || response.status === 201) {
const json = await response.json();
return json.alias;
}
if (response.status === 401) {
throw "Invalid SimpleLogin API key.";
}
const json = await response.json();
if (json?.error != null) {
throw "SimpleLogin error:" + json.error;
}
throw "Unknown SimpleLogin error occurred.";
}
}

View File

@ -1,109 +0,0 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key";
import { BufferedState } from "../../state/buffered-state";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../key-definitions";
import { ForwarderGeneratorStrategy } from "./forwarder-generator-strategy";
import { DefaultDuckDuckGoOptions } from "./forwarders/duck-duck-go";
import { ApiOptions } from "./options/forwarder-options";
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);
},
);
});
});

View File

@ -1,92 +0,0 @@
import { map } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.service";
import { SingleUserState, StateProvider, UserKeyDefinition } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { BufferedKeyDefinition } from "../../state/buffered-key-definition";
import { BufferedState } from "../../state/buffered-state";
import { PaddedDataPacker } from "../../state/padded-data-packer";
import { SecretClassifier } from "../../state/secret-classifier";
import { SecretKeyDefinition } from "../../state/secret-key-definition";
import { SecretState } from "../../state/secret-state";
import { UserKeyEncryptor } from "../../state/user-key-encryptor";
import { GeneratorStrategy } from "../abstractions";
import { NoPolicy } from "../no-policy";
import { newDefaultEvaluator } from "../rx-operators";
import { clone$PerUserId, sharedByUserId } from "../util";
import { ApiOptions } from "./options/forwarder-options";
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;
}
}

View File

@ -1,236 +0,0 @@
/**
* include Request in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { firstValueFrom } from "rxjs";
import { UserId } from "../../../../types/guid";
import { ADDY_IO_FORWARDER } from "../../key-definitions";
import { Forwarders } from "../options/constants";
import { AddyIoForwarder, DefaultAddyIoOptions } 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,
);
},
);
});
});

View File

@ -1,106 +0,0 @@
import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state";
import { ADDY_IO_FORWARDER, ADDY_IO_BUFFER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants";
import { EmailDomainOptions, SelfHostedApiOptions } from "../options/forwarder-options";
export const DefaultAddyIoOptions: SelfHostedApiOptions & EmailDomainOptions = Object.freeze({
website: null,
baseUrl: "https://app.addy.io",
token: "",
domain: "",
});
/** 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: "",
});

View File

@ -1,147 +0,0 @@
/**
* include Request in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { firstValueFrom } from "rxjs";
import { UserId } from "../../../../types/guid";
import { DUCK_DUCK_GO_FORWARDER } from "../../key-definitions";
import { Forwarders } from "../options/constants";
import { DuckDuckGoForwarder, DefaultDuckDuckGoOptions } 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,
);
},
);
});
});

View File

@ -1,79 +0,0 @@
import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state";
import { DUCK_DUCK_GO_FORWARDER, DUCK_DUCK_GO_BUFFER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants";
import { ApiOptions } from "../options/forwarder-options";
export const DefaultDuckDuckGoOptions: ApiOptions = Object.freeze({
website: null,
token: "",
});
/** 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: "",
});

View File

@ -1,284 +0,0 @@
/**
* include Request in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { firstValueFrom } from "rxjs";
import { ApiService } from "../../../../abstractions/api.service";
import { UserId } from "../../../../types/guid";
import { FASTMAIL_FORWARDER } from "../../key-definitions";
import { Forwarders } from "../options/constants";
import { FastmailForwarder, DefaultFastmailOptions } 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,
);
},
);
});
});

View File

@ -1,156 +0,0 @@
import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state";
import { FASTMAIL_FORWARDER, FASTMAIL_BUFFER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants";
import { EmailPrefixOptions, ApiOptions } from "../options/forwarder-options";
export const DefaultFastmailOptions: ApiOptions & EmailPrefixOptions = Object.freeze({
website: "",
domain: "",
prefix: "",
token: "",
});
/** 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: "",
});

View File

@ -1,150 +0,0 @@
/**
* include Request in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { firstValueFrom } from "rxjs";
import { UserId } from "../../../../types/guid";
import { FIREFOX_RELAY_FORWARDER } from "../../key-definitions";
import { Forwarders } from "../options/constants";
import { FirefoxRelayForwarder, DefaultFirefoxRelayOptions } 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,
);
},
);
});
});

View File

@ -1,86 +0,0 @@
import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state";
import { FIREFOX_RELAY_FORWARDER, FIREFOX_RELAY_BUFFER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants";
import { ApiOptions } from "../options/forwarder-options";
export const DefaultFirefoxRelayOptions: ApiOptions = Object.freeze({
website: null,
token: "",
});
/** 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: "",
});

View File

@ -1,280 +0,0 @@
/**
* include Request in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { firstValueFrom } from "rxjs";
import { UserId } from "../../../../types/guid";
import { FORWARD_EMAIL_FORWARDER } from "../../key-definitions";
import { Forwarders } from "../options/constants";
import { ForwardEmailForwarder, DefaultForwardEmailOptions } 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,
);
},
);
});
});

View File

@ -1,109 +0,0 @@
import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { Utils } from "../../../../platform/misc/utils";
import { StateProvider } from "../../../../platform/state";
import { FORWARD_EMAIL_FORWARDER, FORWARD_EMAIL_BUFFER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants";
import { EmailDomainOptions, ApiOptions } from "../options/forwarder-options";
export const DefaultForwardEmailOptions: ApiOptions & EmailDomainOptions = Object.freeze({
website: null,
token: "",
domain: "",
});
/** 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: "",
});

View File

@ -1,22 +0,0 @@
import { ApiService } from "../../../../abstractions/api.service";
import { I18nService } from "../../../../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;
}

View File

@ -1,212 +0,0 @@
/**
* include Request in test environment.
* @jest-environment ../../../../shared/test.environment.ts
*/
import { firstValueFrom } from "rxjs";
import { UserId } from "../../../../types/guid";
import { SIMPLE_LOGIN_FORWARDER } from "../../key-definitions";
import { Forwarders } from "../options/constants";
import { mockApiService, mockI18nService } from "./mocks.jest";
import { SimpleLoginForwarder, DefaultSimpleLoginOptions } 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,
);
},
);
});
});

View File

@ -1,93 +0,0 @@
import { ApiService } from "../../../../abstractions/api.service";
import { CryptoService } from "../../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
import { I18nService } from "../../../../platform/abstractions/i18n.service";
import { StateProvider } from "../../../../platform/state";
import { SIMPLE_LOGIN_FORWARDER, SIMPLE_LOGIN_BUFFER } from "../../key-definitions";
import { ForwarderGeneratorStrategy } from "../forwarder-generator-strategy";
import { Forwarders } from "../options/constants";
import { SelfHostedApiOptions } from "../options/forwarder-options";
export const DefaultSimpleLoginOptions: SelfHostedApiOptions = Object.freeze({
website: null,
baseUrl: "https://app.simplelogin.io",
token: "",
});
/** 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: "",
});

View File

@ -1,5 +0,0 @@
export { EffUsernameGeneratorStrategy } from "./eff-username-generator-strategy";
export { CatchallGeneratorStrategy } from "./catchall-generator-strategy";
export { SubaddressGeneratorStrategy } from "./subaddress-generator-strategy";
export { UsernameGeneratorOptions } from "./username-generation-options";
export { UsernameGenerationServiceAbstraction } from "../abstractions/username-generation.service.abstraction";

View File

@ -1,49 +0,0 @@
import { ForwarderMetadata } from "./forwarder-options";
/** 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),
});

View File

@ -1,72 +0,0 @@
/** 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;
};

View File

@ -1,13 +0,0 @@
/** 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";

View File

@ -1 +0,0 @@
export { ForwarderId, ForwarderMetadata } from "./forwarder-options";

View File

@ -1,18 +0,0 @@
import { RequestOptions } from "./options/forwarder-options";
import { UsernameGenerationMode } from "./options/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;
/** The default options for email subaddress generation. */
export const DefaultSubaddressOptions: SubaddressGenerationOptions = Object.freeze({
subaddressType: "random",
subaddressEmail: "",
website: null,
});

View File

@ -1,75 +0,0 @@
import { mock } from "jest-mock-extended";
import { of, firstValueFrom } from "rxjs";
import { PolicyType } from "../../../admin-console/enums";
// FIXME: use index.ts imports once policy abstractions and models
// implement ADR-0002
import { Policy } from "../../../admin-console/models/domain/policy";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { Randomizer } from "../abstractions/randomizer";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { SUBADDRESS_SETTINGS } from "../key-definitions";
import { DefaultSubaddressOptions } from "./subaddress-generator-options";
import { SubaddressGeneratorStrategy } from ".";
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");
});
});

View File

@ -1,65 +0,0 @@
import { PolicyType } from "../../../admin-console/enums";
import { StateProvider } from "../../../platform/state";
import { GeneratorStrategy } from "../abstractions";
import { Randomizer } from "../abstractions/randomizer";
import { SUBADDRESS_SETTINGS } from "../key-definitions";
import { NoPolicy } from "../no-policy";
import { newDefaultEvaluator } from "../rx-operators";
import { clone$PerUserId, sharedStateByUserId } from "../util";
import {
DefaultSubaddressOptions,
SubaddressGenerationOptions,
} from "./subaddress-generator-options";
/** 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;
}
}

View File

@ -1,23 +0,0 @@
import { CatchallGenerationOptions } from "./catchall-generator-options";
import { EffUsernameGenerationOptions } from "./eff-username-generator-options";
import { ForwarderId, RequestOptions } from "./options/forwarder-options";
import { UsernameGeneratorType } from "./options/generator-options";
import { SubaddressGenerationOptions } from "./subaddress-generator-options";
export type UsernameGeneratorOptions = EffUsernameGenerationOptions &
SubaddressGenerationOptions &
CatchallGenerationOptions &
RequestOptions & {
type?: UsernameGeneratorType;
forwardedService?: ForwarderId | "";
forwardedAnonAddyApiToken?: string;
forwardedAnonAddyDomain?: string;
forwardedAnonAddyBaseUrl?: string;
forwardedDuckDuckGoToken?: string;
forwardedFirefoxApiToken?: string;
forwardedFastmailApiToken?: string;
forwardedForwardEmailApiToken?: string;
forwardedForwardEmailDomain?: string;
forwardedSimpleLoginApiKey?: string;
forwardedSimpleLoginBaseUrl?: string;
};

View File

@ -1,41 +0,0 @@
import { BehaviorSubject } from "rxjs";
import { SingleUserState, StateProvider, UserKeyDefinition } from "../../platform/state";
import { UserId } from "../../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);
}

View File

@ -1,6 +0,0 @@
export type WordOptions = {
/** set the first letter uppercase */
titleCase?: boolean;
/** append a number */
number?: boolean;
};