[PM-5780] New username generation settings types (#7613)
Split from #6924
This commit is contained in:
parent
7ffbeb8215
commit
d5738b7483
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
}
|
Loading…
Reference in New Issue