[PM-5780] New username generation settings types (#7613)

Split from  #6924
This commit is contained in:
✨ Audrey ✨ 2024-01-23 09:52:20 -05:00 committed by GitHub
parent 7ffbeb8215
commit d5738b7483
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 885 additions and 0 deletions

View 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: "",
}),
}),
});

View File

@ -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;
};

View File

@ -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;
};
};

View File

@ -0,0 +1,3 @@
export { UsernameGeneratorOptions } from "./generator-options";
export { DefaultOptions } from "./constants";
export { ForwarderId, ForwarderMetadata } from "./forwarder-options";

View File

@ -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);
},
);
});
});

View 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 };
}