From d5738b7483f9327d2d8226d338744e80ad688ad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 23 Jan 2024 09:52:20 -0500 Subject: [PATCH] [PM-5780] New username generation settings types (#7613) Split from #6924 --- .../generator/username/options/constants.ts | 112 +++++ .../username/options/forwarder-options.ts | 76 ++++ .../username/options/generator-options.ts | 98 +++++ .../tools/generator/username/options/index.ts | 3 + .../username/options/utilities.spec.ts | 408 ++++++++++++++++++ .../generator/username/options/utilities.ts | 188 ++++++++ 6 files changed, 885 insertions(+) create mode 100644 libs/common/src/tools/generator/username/options/constants.ts create mode 100644 libs/common/src/tools/generator/username/options/forwarder-options.ts create mode 100644 libs/common/src/tools/generator/username/options/generator-options.ts create mode 100644 libs/common/src/tools/generator/username/options/index.ts create mode 100644 libs/common/src/tools/generator/username/options/utilities.spec.ts create mode 100644 libs/common/src/tools/generator/username/options/utilities.ts diff --git a/libs/common/src/tools/generator/username/options/constants.ts b/libs/common/src/tools/generator/username/options/constants.ts new file mode 100644 index 0000000000..66f5d83af9 --- /dev/null +++ b/libs/common/src/tools/generator/username/options/constants.ts @@ -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: "", + }), + }), +}); diff --git a/libs/common/src/tools/generator/username/options/forwarder-options.ts b/libs/common/src/tools/generator/username/options/forwarder-options.ts new file mode 100644 index 0000000000..02375726c8 --- /dev/null +++ b/libs/common/src/tools/generator/username/options/forwarder-options.ts @@ -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; +} + +/** 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; +}; diff --git a/libs/common/src/tools/generator/username/options/generator-options.ts b/libs/common/src/tools/generator/username/options/generator-options.ts new file mode 100644 index 0000000000..11fb045b62 --- /dev/null +++ b/libs/common/src/tools/generator/username/options/generator-options.ts @@ -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; + }; +}; diff --git a/libs/common/src/tools/generator/username/options/index.ts b/libs/common/src/tools/generator/username/options/index.ts new file mode 100644 index 0000000000..b76e5eaf51 --- /dev/null +++ b/libs/common/src/tools/generator/username/options/index.ts @@ -0,0 +1,3 @@ +export { UsernameGeneratorOptions } from "./generator-options"; +export { DefaultOptions } from "./constants"; +export { ForwarderId, ForwarderMetadata } from "./forwarder-options"; diff --git a/libs/common/src/tools/generator/username/options/utilities.spec.ts b/libs/common/src/tools/generator/username/options/utilities.spec.ts new file mode 100644 index 0000000000..0152220ee4 --- /dev/null +++ b/libs/common/src/tools/generator/username/options/utilities.spec.ts @@ -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); + }, + ); + }); +}); diff --git a/libs/common/src/tools/generator/username/options/utilities.ts b/libs/common/src/tools/generator/username/options/utilities.ts new file mode 100644 index 0000000000..46013fff90 --- /dev/null +++ b/libs/common/src/tools/generator/username/options/utilities.ts @@ -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( + 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(value: T, defaults: Partial): 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 }; +}