mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 08:13:42 +00:00
[PM-5780] New username generation settings types (#7613)
Split from #6924
This commit is contained in:
112
libs/common/src/tools/generator/username/options/constants.ts
Normal file
112
libs/common/src/tools/generator/username/options/constants.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { ForwarderMetadata } from "./forwarder-options";
|
||||||
|
import { UsernameGeneratorOptions } from "./generator-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),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Padding values used to prevent leaking the length of the encrypted options. */
|
||||||
|
export const SecretPadding = Object.freeze({
|
||||||
|
/** The length to pad out encrypted members. This should be at least as long
|
||||||
|
* as the JSON content for the longest JSON payload being encrypted.
|
||||||
|
*/
|
||||||
|
length: 512,
|
||||||
|
|
||||||
|
/** The character to use for padding. */
|
||||||
|
character: "0",
|
||||||
|
|
||||||
|
/** A regular expression for detecting invalid padding. When the character
|
||||||
|
* changes, this should be updated to include the new padding pattern.
|
||||||
|
*/
|
||||||
|
hasInvalidPadding: /[^0]/,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Default options for username generation. */
|
||||||
|
// freeze all the things to prevent mutation
|
||||||
|
export const DefaultOptions: UsernameGeneratorOptions = Object.freeze({
|
||||||
|
type: "word",
|
||||||
|
website: "",
|
||||||
|
word: Object.freeze({
|
||||||
|
capitalize: true,
|
||||||
|
includeNumber: true,
|
||||||
|
}),
|
||||||
|
subaddress: Object.freeze({
|
||||||
|
algorithm: "random",
|
||||||
|
email: "",
|
||||||
|
}),
|
||||||
|
catchall: Object.freeze({
|
||||||
|
algorithm: "random",
|
||||||
|
domain: "",
|
||||||
|
}),
|
||||||
|
forwarders: Object.freeze({
|
||||||
|
service: Forwarders.Fastmail.id,
|
||||||
|
fastMail: Object.freeze({
|
||||||
|
domain: "",
|
||||||
|
prefix: "",
|
||||||
|
token: "",
|
||||||
|
}),
|
||||||
|
addyIo: Object.freeze({
|
||||||
|
baseUrl: "https://app.addy.io",
|
||||||
|
domain: "",
|
||||||
|
token: "",
|
||||||
|
}),
|
||||||
|
forwardEmail: Object.freeze({
|
||||||
|
token: "",
|
||||||
|
domain: "",
|
||||||
|
}),
|
||||||
|
simpleLogin: Object.freeze({
|
||||||
|
baseUrl: "https://app.simplelogin.io",
|
||||||
|
token: "",
|
||||||
|
}),
|
||||||
|
duckDuckGo: Object.freeze({
|
||||||
|
token: "",
|
||||||
|
}),
|
||||||
|
firefoxRelay: Object.freeze({
|
||||||
|
token: "",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { EncString } from "../../../../platform/models/domain/enc-string";
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** An email forwarding service configurable through an API. */
|
||||||
|
export interface Forwarder {
|
||||||
|
/** Generate a forwarding email.
|
||||||
|
* @param website The website to generate a username for.
|
||||||
|
* @param options The options to use when generating the username.
|
||||||
|
*/
|
||||||
|
generate(website: string | null, options: ApiOptions): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
|
||||||
|
/** encrypted bearer token that authenticates bitwarden to the forwarder.
|
||||||
|
* This is used to store the token at rest and must be decoded before use.
|
||||||
|
*/
|
||||||
|
encryptedToken?: EncString;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
ApiOptions,
|
||||||
|
EmailDomainOptions,
|
||||||
|
EmailPrefixOptions,
|
||||||
|
ForwarderId,
|
||||||
|
SelfHostedApiOptions,
|
||||||
|
} from "./forwarder-options";
|
||||||
|
|
||||||
|
/** Configuration for username generation algorithms. */
|
||||||
|
export type AlgorithmOptions = {
|
||||||
|
/** selects the generation algorithm for the username.
|
||||||
|
* "random" generates a random string.
|
||||||
|
* "website-name" generates a username based on the website's name.
|
||||||
|
*/
|
||||||
|
algorithm: "random" | "website-name";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Identifies encrypted options that could have leaked from the configuration. */
|
||||||
|
export type MaybeLeakedOptions = {
|
||||||
|
/** When true, encrypted options were previously stored as plaintext.
|
||||||
|
* @remarks This is used to alert the user that the token should be
|
||||||
|
* regenerated. If a token has always been stored encrypted,
|
||||||
|
* this should be omitted.
|
||||||
|
*/
|
||||||
|
wasPlainText?: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Options for generating a username.
|
||||||
|
* @remarks This type includes all fields so that the generator
|
||||||
|
* remembers the user's configuration for each type of username
|
||||||
|
* and forwarder.
|
||||||
|
*/
|
||||||
|
export type UsernameGeneratorOptions = {
|
||||||
|
/** selects the property group used for username generation */
|
||||||
|
type?: "word" | "subaddress" | "catchall" | "forwarded";
|
||||||
|
|
||||||
|
/** When generating a forwarding address for a vault item, this should contain
|
||||||
|
* the domain the vault item supplies to the generator.
|
||||||
|
* @example If the user is creating a vault item for `https://www.domain.io/login`,
|
||||||
|
* then this should be `www.domain.io`.
|
||||||
|
*/
|
||||||
|
website?: string;
|
||||||
|
|
||||||
|
/** When true, the username generator saves options immediately
|
||||||
|
* after they're loaded. Otherwise this option should not be defined.
|
||||||
|
* */
|
||||||
|
saveOnLoad?: true;
|
||||||
|
|
||||||
|
/* Configures generation of a username from the EFF word list */
|
||||||
|
word: {
|
||||||
|
/** when true, the word is capitalized */
|
||||||
|
capitalize?: boolean;
|
||||||
|
|
||||||
|
/** when true, a random number is appended to the username */
|
||||||
|
includeNumber?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Configures generation of 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`.
|
||||||
|
*/
|
||||||
|
subaddress: AlgorithmOptions & {
|
||||||
|
/** the email address the subaddress is applied to. */
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Configures generation for a domain catch-all address.
|
||||||
|
*/
|
||||||
|
catchall: AlgorithmOptions & EmailDomainOptions;
|
||||||
|
|
||||||
|
/** Configures generation for an email forwarding service address.
|
||||||
|
*/
|
||||||
|
forwarders: {
|
||||||
|
/** The service to use for email forwarding.
|
||||||
|
* @remarks This determines which forwarder-specific options to use.
|
||||||
|
*/
|
||||||
|
service?: ForwarderId;
|
||||||
|
|
||||||
|
/** {@link Forwarders.AddyIo} */
|
||||||
|
addyIo: SelfHostedApiOptions & EmailDomainOptions & MaybeLeakedOptions;
|
||||||
|
|
||||||
|
/** {@link Forwarders.DuckDuckGo} */
|
||||||
|
duckDuckGo: ApiOptions & MaybeLeakedOptions;
|
||||||
|
|
||||||
|
/** {@link Forwarders.FastMail} */
|
||||||
|
fastMail: ApiOptions & EmailPrefixOptions & MaybeLeakedOptions;
|
||||||
|
|
||||||
|
/** {@link Forwarders.FireFoxRelay} */
|
||||||
|
firefoxRelay: ApiOptions & MaybeLeakedOptions;
|
||||||
|
|
||||||
|
/** {@link Forwarders.ForwardEmail} */
|
||||||
|
forwardEmail: ApiOptions & EmailDomainOptions & MaybeLeakedOptions;
|
||||||
|
|
||||||
|
/** {@link forwarders.SimpleLogin} */
|
||||||
|
simpleLogin: SelfHostedApiOptions & MaybeLeakedOptions;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { UsernameGeneratorOptions } from "./generator-options";
|
||||||
|
export { DefaultOptions } from "./constants";
|
||||||
|
export { ForwarderId, ForwarderMetadata } from "./forwarder-options";
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* include structuredClone in test environment.
|
||||||
|
* @jest-environment ../../../../shared/test.environment.ts
|
||||||
|
*/
|
||||||
|
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 { DefaultOptions, Forwarders } from "./constants";
|
||||||
|
import { ApiOptions } from "./forwarder-options";
|
||||||
|
import { UsernameGeneratorOptions, MaybeLeakedOptions } from "./generator-options";
|
||||||
|
import {
|
||||||
|
getForwarderOptions,
|
||||||
|
falsyDefault,
|
||||||
|
encryptInPlace,
|
||||||
|
decryptInPlace,
|
||||||
|
forAllForwarders,
|
||||||
|
} from "./utilities";
|
||||||
|
|
||||||
|
const TestOptions: UsernameGeneratorOptions = {
|
||||||
|
type: "word",
|
||||||
|
website: "example.com",
|
||||||
|
word: {
|
||||||
|
capitalize: true,
|
||||||
|
includeNumber: true,
|
||||||
|
},
|
||||||
|
subaddress: {
|
||||||
|
algorithm: "random",
|
||||||
|
email: "foo@example.com",
|
||||||
|
},
|
||||||
|
catchall: {
|
||||||
|
algorithm: "random",
|
||||||
|
domain: "example.com",
|
||||||
|
},
|
||||||
|
forwarders: {
|
||||||
|
service: Forwarders.Fastmail.id,
|
||||||
|
fastMail: {
|
||||||
|
domain: "httpbin.com",
|
||||||
|
prefix: "foo",
|
||||||
|
token: "some-token",
|
||||||
|
},
|
||||||
|
addyIo: {
|
||||||
|
baseUrl: "https://app.addy.io",
|
||||||
|
domain: "example.com",
|
||||||
|
token: "some-token",
|
||||||
|
},
|
||||||
|
forwardEmail: {
|
||||||
|
token: "some-token",
|
||||||
|
domain: "example.com",
|
||||||
|
},
|
||||||
|
simpleLogin: {
|
||||||
|
baseUrl: "https://app.simplelogin.io",
|
||||||
|
token: "some-token",
|
||||||
|
},
|
||||||
|
duckDuckGo: {
|
||||||
|
token: "some-token",
|
||||||
|
},
|
||||||
|
firefoxRelay: {
|
||||||
|
token: "some-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockEncryptService(): EncryptService {
|
||||||
|
return {
|
||||||
|
encrypt: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((plainText: string, _key: SymmetricCryptoKey) => plainText),
|
||||||
|
decryptToUtf8: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((cryptoText: string, _key: SymmetricCryptoKey) => cryptoText),
|
||||||
|
} as unknown as EncryptService;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Username Generation Options", () => {
|
||||||
|
describe("forAllForwarders", () => {
|
||||||
|
it("runs the function on every forwarder.", () => {
|
||||||
|
const result = forAllForwarders(TestOptions, (_, id) => id);
|
||||||
|
expect(result).toEqual([
|
||||||
|
"anonaddy",
|
||||||
|
"duckduckgo",
|
||||||
|
"fastmail",
|
||||||
|
"firefoxrelay",
|
||||||
|
"forwardemail",
|
||||||
|
"simplelogin",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getForwarderOptions", () => {
|
||||||
|
it("should return null for unsupported services", () => {
|
||||||
|
expect(getForwarderOptions("unsupported", DefaultOptions)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
let options: UsernameGeneratorOptions = null;
|
||||||
|
beforeEach(() => {
|
||||||
|
options = structuredClone(TestOptions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[TestOptions.forwarders.addyIo, "anonaddy"],
|
||||||
|
[TestOptions.forwarders.duckDuckGo, "duckduckgo"],
|
||||||
|
[TestOptions.forwarders.fastMail, "fastmail"],
|
||||||
|
[TestOptions.forwarders.firefoxRelay, "firefoxrelay"],
|
||||||
|
[TestOptions.forwarders.forwardEmail, "forwardemail"],
|
||||||
|
[TestOptions.forwarders.simpleLogin, "simplelogin"],
|
||||||
|
])("should return an %s for %p", (forwarderOptions, service) => {
|
||||||
|
const forwarder = getForwarderOptions(service, options);
|
||||||
|
expect(forwarder).toEqual(forwarderOptions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return a reference to the forwarder", () => {
|
||||||
|
const forwarder = getForwarderOptions("anonaddy", options);
|
||||||
|
expect(forwarder).toBe(options.forwarders.addyIo);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("falsyDefault", () => {
|
||||||
|
it("should not modify values with truthy items", () => {
|
||||||
|
const input = {
|
||||||
|
a: "a",
|
||||||
|
b: 1,
|
||||||
|
d: [1],
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = falsyDefault(input, {
|
||||||
|
a: "b",
|
||||||
|
b: 2,
|
||||||
|
d: [2],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(output).toEqual(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should modify values with falsy items", () => {
|
||||||
|
const input = {
|
||||||
|
a: "",
|
||||||
|
b: 0,
|
||||||
|
c: false,
|
||||||
|
d: [] as number[],
|
||||||
|
e: [0] as number[],
|
||||||
|
f: null as string,
|
||||||
|
g: undefined as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = falsyDefault(input, {
|
||||||
|
a: "a",
|
||||||
|
b: 1,
|
||||||
|
c: true,
|
||||||
|
d: [1],
|
||||||
|
e: [1],
|
||||||
|
f: "a",
|
||||||
|
g: "a",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(output).toEqual({
|
||||||
|
a: "a",
|
||||||
|
b: 1,
|
||||||
|
c: true,
|
||||||
|
d: [1],
|
||||||
|
e: [1],
|
||||||
|
f: "a",
|
||||||
|
g: "a",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should traverse nested objects", () => {
|
||||||
|
const input = {
|
||||||
|
a: {
|
||||||
|
b: {
|
||||||
|
c: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = falsyDefault(input, {
|
||||||
|
a: {
|
||||||
|
b: {
|
||||||
|
c: "c",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(output).toEqual({
|
||||||
|
a: {
|
||||||
|
b: {
|
||||||
|
c: "c",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add missing defaults", () => {
|
||||||
|
const input = {};
|
||||||
|
|
||||||
|
const output = falsyDefault(input, {
|
||||||
|
a: "a",
|
||||||
|
b: [1],
|
||||||
|
c: {},
|
||||||
|
d: { e: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(output).toEqual({
|
||||||
|
a: "a",
|
||||||
|
b: [1],
|
||||||
|
c: {},
|
||||||
|
d: { e: 1 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore missing defaults", () => {
|
||||||
|
const input = {
|
||||||
|
a: "",
|
||||||
|
b: 0,
|
||||||
|
c: false,
|
||||||
|
d: [] as number[],
|
||||||
|
e: [0] as number[],
|
||||||
|
f: null as string,
|
||||||
|
g: undefined as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = falsyDefault(input, {});
|
||||||
|
|
||||||
|
expect(output).toEqual({
|
||||||
|
a: "",
|
||||||
|
b: 0,
|
||||||
|
c: false,
|
||||||
|
d: [] as number[],
|
||||||
|
e: [0] as number[],
|
||||||
|
f: null as string,
|
||||||
|
g: undefined as string,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([[null], [undefined]])("should ignore %p defaults", (defaults) => {
|
||||||
|
const input = {
|
||||||
|
a: "",
|
||||||
|
b: 0,
|
||||||
|
c: false,
|
||||||
|
d: [] as number[],
|
||||||
|
e: [0] as number[],
|
||||||
|
f: null as string,
|
||||||
|
g: undefined as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
const output = falsyDefault(input, defaults);
|
||||||
|
|
||||||
|
expect(output).toEqual({
|
||||||
|
a: "",
|
||||||
|
b: 0,
|
||||||
|
c: false,
|
||||||
|
d: [] as number[],
|
||||||
|
e: [0] as number[],
|
||||||
|
f: null as string,
|
||||||
|
g: undefined as string,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("encryptInPlace", () => {
|
||||||
|
it("should return without encrypting if a token was not supplied", async () => {
|
||||||
|
const encryptService = mockEncryptService();
|
||||||
|
|
||||||
|
// throws if modified, failing the test
|
||||||
|
const options = Object.freeze({});
|
||||||
|
await encryptInPlace(encryptService, null, options);
|
||||||
|
|
||||||
|
expect(encryptService.encrypt).toBeCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a token", { token: "a token" }, `{"token":"a token"}${"0".repeat(493)}`, "a key"],
|
||||||
|
[
|
||||||
|
"a token and wasPlainText",
|
||||||
|
{ token: "a token", wasPlainText: true },
|
||||||
|
`{"token":"a token","wasPlainText":true}${"0".repeat(473)}`,
|
||||||
|
"another key",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a really long token",
|
||||||
|
{ token: `a ${"really ".repeat(50)}long token` },
|
||||||
|
`{"token":"a ${"really ".repeat(50)}long token"}${"0".repeat(138)}`,
|
||||||
|
"a third key",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a really long token and wasPlainText",
|
||||||
|
{ token: `a ${"really ".repeat(50)}long token`, wasPlainText: true },
|
||||||
|
`{"token":"a ${"really ".repeat(50)}long token","wasPlainText":true}${"0".repeat(118)}`,
|
||||||
|
"a key",
|
||||||
|
],
|
||||||
|
] as unknown as [string, ApiOptions & MaybeLeakedOptions, string, SymmetricCryptoKey][])(
|
||||||
|
"encrypts %s and removes encrypted values",
|
||||||
|
async (_description, options, encryptedToken, key) => {
|
||||||
|
const encryptService = mockEncryptService();
|
||||||
|
|
||||||
|
await encryptInPlace(encryptService, key, options);
|
||||||
|
|
||||||
|
expect(options.encryptedToken).toEqual(encryptedToken);
|
||||||
|
expect(options).not.toHaveProperty("token");
|
||||||
|
expect(options).not.toHaveProperty("wasPlainText");
|
||||||
|
|
||||||
|
// Why `encryptedToken`? The mock outputs its input without encryption.
|
||||||
|
expect(encryptService.encrypt).toBeCalledWith(encryptedToken, key);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decryptInPlace", () => {
|
||||||
|
it("should return without decrypting if an encryptedToken was not supplied", async () => {
|
||||||
|
const encryptService = mockEncryptService();
|
||||||
|
|
||||||
|
// throws if modified, failing the test
|
||||||
|
const options = Object.freeze({});
|
||||||
|
await decryptInPlace(encryptService, null, options);
|
||||||
|
|
||||||
|
expect(encryptService.decryptToUtf8).toBeCalledTimes(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a simple token", `{"token":"a token"}${"0".repeat(493)}`, { token: "a token" }, "a key"],
|
||||||
|
[
|
||||||
|
"a simple leaked token",
|
||||||
|
`{"token":"a token","wasPlainText":true}${"0".repeat(473)}`,
|
||||||
|
{ token: "a token", wasPlainText: true },
|
||||||
|
"another key",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a long token",
|
||||||
|
`{"token":"a ${"really ".repeat(50)}long token"}${"0".repeat(138)}`,
|
||||||
|
{ token: `a ${"really ".repeat(50)}long token` },
|
||||||
|
"a third key",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"a long leaked token",
|
||||||
|
`{"token":"a ${"really ".repeat(50)}long token","wasPlainText":true}${"0".repeat(118)}`,
|
||||||
|
{ token: `a ${"really ".repeat(50)}long token`, wasPlainText: true },
|
||||||
|
"a key",
|
||||||
|
],
|
||||||
|
] as [string, string, ApiOptions & MaybeLeakedOptions, string][])(
|
||||||
|
"decrypts %s and removes encrypted values",
|
||||||
|
async (_description, encryptedTokenString, expectedOptions, keyString) => {
|
||||||
|
const encryptService = mockEncryptService();
|
||||||
|
|
||||||
|
// cast through unknown to avoid type errors; the mock doesn't need the real types
|
||||||
|
// since it just outputs its input
|
||||||
|
const key = keyString as unknown as SymmetricCryptoKey;
|
||||||
|
const encryptedToken = encryptedTokenString as unknown as EncString;
|
||||||
|
|
||||||
|
const actualOptions = { encryptedToken } as any;
|
||||||
|
|
||||||
|
await decryptInPlace(encryptService, key, actualOptions);
|
||||||
|
|
||||||
|
expect(actualOptions.token).toEqual(expectedOptions.token);
|
||||||
|
expect(actualOptions.wasPlainText).toEqual(expectedOptions.wasPlainText);
|
||||||
|
expect(actualOptions).not.toHaveProperty("encryptedToken");
|
||||||
|
|
||||||
|
// Why `encryptedToken`? The mock outputs its input without encryption.
|
||||||
|
expect(encryptService.decryptToUtf8).toBeCalledWith(encryptedToken, key);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["invalid length", "invalid length", "invalid"],
|
||||||
|
["all padding", "missing json object", `${"0".repeat(512)}`],
|
||||||
|
[
|
||||||
|
"invalid padding",
|
||||||
|
"invalid padding",
|
||||||
|
`{"token":"a token","wasPlainText":true} ${"0".repeat(472)}`,
|
||||||
|
],
|
||||||
|
["only closing brace", "invalid json", `}${"0".repeat(511)}`],
|
||||||
|
["token is NaN", "invalid json", `{"token":NaN}${"0".repeat(499)}`],
|
||||||
|
["only unknown key", "unknown keys", `{"unknown":"key"}${"0".repeat(495)}`],
|
||||||
|
["unknown key", "unknown keys", `{"token":"some token","unknown":"key"}${"0".repeat(474)}`],
|
||||||
|
[
|
||||||
|
"unknown key with wasPlainText",
|
||||||
|
"unknown keys",
|
||||||
|
`{"token":"some token","wasPlainText":true,"unknown":"key"}${"0".repeat(454)}`,
|
||||||
|
],
|
||||||
|
["empty json object", "invalid token", `{}${"0".repeat(510)}`],
|
||||||
|
["token is a number", "invalid token", `{"token":5}${"0".repeat(501)}`],
|
||||||
|
[
|
||||||
|
"wasPlainText is false",
|
||||||
|
"invalid wasPlainText",
|
||||||
|
`{"token":"foo","wasPlainText":false}${"0".repeat(476)}`,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"wasPlainText is string",
|
||||||
|
"invalid wasPlainText",
|
||||||
|
`{"token":"foo","wasPlainText":"fal"}${"0".repeat(476)}`,
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
"should delete untrusted encrypted values (description %s, reason: %s) ",
|
||||||
|
async (_description, expectedReason, encryptedToken) => {
|
||||||
|
const encryptService = mockEncryptService();
|
||||||
|
|
||||||
|
// cast through unknown to avoid type errors; the mock doesn't need the real types
|
||||||
|
// since it just outputs its input
|
||||||
|
const key: SymmetricCryptoKey = "a key" as unknown as SymmetricCryptoKey;
|
||||||
|
const options = { encryptedToken: encryptedToken as unknown as EncString };
|
||||||
|
|
||||||
|
const reason = await decryptInPlace(encryptService, key, options);
|
||||||
|
|
||||||
|
expect(options).not.toHaveProperty("encryptedToken");
|
||||||
|
expect(reason).toEqual(expectedReason);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
188
libs/common/src/tools/generator/username/options/utilities.ts
Normal file
188
libs/common/src/tools/generator/username/options/utilities.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { EncryptService } from "../../../../platform/abstractions/encrypt.service";
|
||||||
|
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||||
|
|
||||||
|
import { DefaultOptions, Forwarders, SecretPadding } from "./constants";
|
||||||
|
import { ApiOptions, ForwarderId } from "./forwarder-options";
|
||||||
|
import { MaybeLeakedOptions, UsernameGeneratorOptions } from "./generator-options";
|
||||||
|
|
||||||
|
/** runs the callback on each forwarder configuration */
|
||||||
|
export function forAllForwarders<T>(
|
||||||
|
options: UsernameGeneratorOptions,
|
||||||
|
callback: (options: ApiOptions, id: ForwarderId) => T,
|
||||||
|
) {
|
||||||
|
const results = [];
|
||||||
|
for (const forwarder of Object.values(Forwarders).map((f) => f.id)) {
|
||||||
|
const forwarderOptions = getForwarderOptions(forwarder, options);
|
||||||
|
if (forwarderOptions) {
|
||||||
|
results.push(callback(forwarderOptions, forwarder));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets the options for the specified forwarding service with defaults applied.
|
||||||
|
* This method mutates `options`.
|
||||||
|
* @param service Identifies the service whose options should be loaded.
|
||||||
|
* @param options The options to load from.
|
||||||
|
* @returns A reference to the options for the specified service.
|
||||||
|
*/
|
||||||
|
export function getForwarderOptions(
|
||||||
|
service: string,
|
||||||
|
options: UsernameGeneratorOptions,
|
||||||
|
): ApiOptions & MaybeLeakedOptions {
|
||||||
|
if (service === Forwarders.AddyIo.id) {
|
||||||
|
return falsyDefault(options.forwarders.addyIo, DefaultOptions.forwarders.addyIo);
|
||||||
|
} else if (service === Forwarders.DuckDuckGo.id) {
|
||||||
|
return falsyDefault(options.forwarders.duckDuckGo, DefaultOptions.forwarders.duckDuckGo);
|
||||||
|
} else if (service === Forwarders.Fastmail.id) {
|
||||||
|
return falsyDefault(options.forwarders.fastMail, DefaultOptions.forwarders.fastMail);
|
||||||
|
} else if (service === Forwarders.FirefoxRelay.id) {
|
||||||
|
return falsyDefault(options.forwarders.firefoxRelay, DefaultOptions.forwarders.firefoxRelay);
|
||||||
|
} else if (service === Forwarders.ForwardEmail.id) {
|
||||||
|
return falsyDefault(options.forwarders.forwardEmail, DefaultOptions.forwarders.forwardEmail);
|
||||||
|
} else if (service === Forwarders.SimpleLogin.id) {
|
||||||
|
return falsyDefault(options.forwarders.simpleLogin, DefaultOptions.forwarders.simpleLogin);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively applies default values from `defaults` to falsy or
|
||||||
|
* missing properties in `value`.
|
||||||
|
*
|
||||||
|
* @remarks This method is not aware of the
|
||||||
|
* object's prototype or metadata, such as readonly or frozen fields.
|
||||||
|
* It should only be used on plain objects.
|
||||||
|
*
|
||||||
|
* @param value - The value to fill in. This parameter is mutated.
|
||||||
|
* @param defaults - The default values to use.
|
||||||
|
* @returns the mutated `value`.
|
||||||
|
*/
|
||||||
|
export function falsyDefault<T>(value: T, defaults: Partial<T>): T {
|
||||||
|
// iterate keys in defaults because `value` may be missing keys
|
||||||
|
for (const key in defaults) {
|
||||||
|
if (defaults[key] instanceof Object) {
|
||||||
|
// `any` type is required because typescript can't predict the type of `value[key]`.
|
||||||
|
const target: any = value[key] || (defaults[key] instanceof Array ? [] : {});
|
||||||
|
value[key] = falsyDefault(target, defaults[key]);
|
||||||
|
} else if (!value[key]) {
|
||||||
|
value[key] = defaults[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** encrypts sensitive options and stores them in-place.
|
||||||
|
* @param encryptService The service used to encrypt the options.
|
||||||
|
* @param key The key used to encrypt the options.
|
||||||
|
* @param options The options to encrypt. The encrypted members are
|
||||||
|
* removed from the options and the decrypted members
|
||||||
|
* are added to the options.
|
||||||
|
*/
|
||||||
|
export async function encryptInPlace(
|
||||||
|
encryptService: EncryptService,
|
||||||
|
key: SymmetricCryptoKey,
|
||||||
|
options: ApiOptions & MaybeLeakedOptions,
|
||||||
|
) {
|
||||||
|
if (!options.token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pick the options that require encryption
|
||||||
|
const encryptOptions = (({ token, wasPlainText }) => ({ token, wasPlainText }))(options);
|
||||||
|
delete options.token;
|
||||||
|
delete options.wasPlainText;
|
||||||
|
|
||||||
|
// don't leak whether a leak was possible by padding the encrypted string.
|
||||||
|
// without this, it could be possible to determine whether the token was
|
||||||
|
// encrypted by checking the length of the encrypted string.
|
||||||
|
const toEncrypt = JSON.stringify(encryptOptions).padEnd(
|
||||||
|
SecretPadding.length,
|
||||||
|
SecretPadding.character,
|
||||||
|
);
|
||||||
|
|
||||||
|
const encrypted = await encryptService.encrypt(toEncrypt, key);
|
||||||
|
options.encryptedToken = encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** decrypts sensitive options and stores them in-place.
|
||||||
|
* @param encryptService The service used to decrypt the options.
|
||||||
|
* @param key The key used to decrypt the options.
|
||||||
|
* @param options The options to decrypt. The encrypted members are
|
||||||
|
* removed from the options and the decrypted members
|
||||||
|
* are added to the options.
|
||||||
|
* @returns null if the options were decrypted successfully, otherwise
|
||||||
|
* a string describing why the options could not be decrypted.
|
||||||
|
* The return values are intended to be used for logging and debugging.
|
||||||
|
* @remarks This method does not throw if the options could not be decrypted
|
||||||
|
* because in such cases there's nothing the user can do to fix it.
|
||||||
|
*/
|
||||||
|
export async function decryptInPlace(
|
||||||
|
encryptService: EncryptService,
|
||||||
|
key: SymmetricCryptoKey,
|
||||||
|
options: ApiOptions & MaybeLeakedOptions,
|
||||||
|
) {
|
||||||
|
if (!options.encryptedToken) {
|
||||||
|
return "missing encryptedToken";
|
||||||
|
}
|
||||||
|
|
||||||
|
const decrypted = await encryptService.decryptToUtf8(options.encryptedToken, key);
|
||||||
|
delete options.encryptedToken;
|
||||||
|
|
||||||
|
// If the decrypted string is not exactly the padding length, it could be compromised
|
||||||
|
// and shouldn't be trusted.
|
||||||
|
if (decrypted.length !== SecretPadding.length) {
|
||||||
|
return "invalid length";
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON terminates with a closing brace, after which the plaintext repeats `character`
|
||||||
|
// If the closing brace is not found, then it could be compromised and shouldn't be trusted.
|
||||||
|
const jsonBreakpoint = decrypted.lastIndexOf("}") + 1;
|
||||||
|
if (jsonBreakpoint < 1) {
|
||||||
|
return "missing json object";
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the padding contains invalid padding characters then the padding could be used
|
||||||
|
// as a side channel for arbitrary data.
|
||||||
|
if (decrypted.substring(jsonBreakpoint).match(SecretPadding.hasInvalidPadding)) {
|
||||||
|
return "invalid padding";
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove padding and parse the JSON
|
||||||
|
const json = decrypted.substring(0, jsonBreakpoint);
|
||||||
|
|
||||||
|
const { decryptedOptions, error } = parseOptions(json);
|
||||||
|
if (error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(options, decryptedOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptions(json: string) {
|
||||||
|
let decryptedOptions = null;
|
||||||
|
try {
|
||||||
|
decryptedOptions = JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
return { decryptedOptions: undefined as string, error: "invalid json" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the decrypted options contain any property that is not in the original
|
||||||
|
// options, then the object could be used as a side channel for arbitrary data.
|
||||||
|
if (Object.keys(decryptedOptions).some((key) => key !== "token" && key !== "wasPlainText")) {
|
||||||
|
return { decryptedOptions: undefined as string, error: "unknown keys" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the decrypted properties are not the expected type, then the object could
|
||||||
|
// be compromised and shouldn't be trusted.
|
||||||
|
if (typeof decryptedOptions.token !== "string") {
|
||||||
|
return { decryptedOptions: undefined as string, error: "invalid token" };
|
||||||
|
}
|
||||||
|
if (decryptedOptions.wasPlainText !== undefined && decryptedOptions.wasPlainText !== true) {
|
||||||
|
return { decryptedOptions: undefined as string, error: "invalid wasPlainText" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { decryptedOptions, error: undefined as string };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user