From 36116bddda73b4028472447296eb4539fe6ed8b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 27 Feb 2024 11:40:32 -0500 Subject: [PATCH] [PM-5614] introduce SecretState wrapper (#7823) Matt provided a ton of help on getting the state interactions right. Both he and Justin collaborated with me to write the core of of the secret classifier. Co-authored-by: Matt Gibson Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> --- libs/common/src/platform/state/index.ts | 1 + .../state/data-packer.abstraction.ts | 21 ++ .../state/padded-data-packer.spec.ts | 101 +++++++++ .../generator/state/padded-data-packer.ts | 94 ++++++++ .../generator/state/secret-classifier.spec.ts | 177 +++++++++++++++ .../generator/state/secret-classifier.ts | 137 ++++++++++++ .../generator/state/secret-state.spec.ts | 207 ++++++++++++++++++ .../src/tools/generator/state/secret-state.ts | 192 ++++++++++++++++ .../state/user-encryptor.abstraction.ts | 42 ++++ .../state/user-key-encryptor.spec.ts | 140 ++++++++++++ .../generator/state/user-key-encryptor.ts | 83 +++++++ .../username/options/utilities.spec.ts | 175 +-------------- .../generator/username/options/utilities.ts | 118 +--------- 13 files changed, 1198 insertions(+), 290 deletions(-) create mode 100644 libs/common/src/tools/generator/state/data-packer.abstraction.ts create mode 100644 libs/common/src/tools/generator/state/padded-data-packer.spec.ts create mode 100644 libs/common/src/tools/generator/state/padded-data-packer.ts create mode 100644 libs/common/src/tools/generator/state/secret-classifier.spec.ts create mode 100644 libs/common/src/tools/generator/state/secret-classifier.ts create mode 100644 libs/common/src/tools/generator/state/secret-state.spec.ts create mode 100644 libs/common/src/tools/generator/state/secret-state.ts create mode 100644 libs/common/src/tools/generator/state/user-encryptor.abstraction.ts create mode 100644 libs/common/src/tools/generator/state/user-key-encryptor.spec.ts create mode 100644 libs/common/src/tools/generator/state/user-key-encryptor.ts diff --git a/libs/common/src/platform/state/index.ts b/libs/common/src/platform/state/index.ts index 504d551b88a..72f3aa155f6 100644 --- a/libs/common/src/platform/state/index.ts +++ b/libs/common/src/platform/state/index.ts @@ -7,6 +7,7 @@ export { GlobalStateProvider } from "./global-state.provider"; export { ActiveUserState, SingleUserState } from "./user-state"; export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider"; export { KeyDefinition } from "./key-definition"; +export { StateUpdateOptions } from "./state-update-options"; export { UserKeyDefinition } from "./user-key-definition"; export * from "./state-definitions"; diff --git a/libs/common/src/tools/generator/state/data-packer.abstraction.ts b/libs/common/src/tools/generator/state/data-packer.abstraction.ts new file mode 100644 index 00000000000..cb712e0fd9b --- /dev/null +++ b/libs/common/src/tools/generator/state/data-packer.abstraction.ts @@ -0,0 +1,21 @@ +import { Jsonify } from "type-fest"; + +/** A packing strategy that packs data into a string. + */ +export abstract class DataPacker { + /** + * Packs value into a string format. + * @type {Data} is the type of data being protected. + * @param value is packed into the string + * @returns the packed string + */ + abstract pack(value: Data): string; + + /** Unpacks a string produced by pack. + * @param packedValue is the string to unpack + * @type {Data} is the type of data being protected. + * @returns the data stored within the secret. + * @throws when `packedValue` has an invalid format. + */ + abstract unpack(packedValue: string): Jsonify; +} diff --git a/libs/common/src/tools/generator/state/padded-data-packer.spec.ts b/libs/common/src/tools/generator/state/padded-data-packer.spec.ts new file mode 100644 index 00000000000..3cf225026b4 --- /dev/null +++ b/libs/common/src/tools/generator/state/padded-data-packer.spec.ts @@ -0,0 +1,101 @@ +import { PaddedDataPacker } from "./padded-data-packer"; + +describe("UserKeyEncryptor", () => { + describe("pack", () => { + it("should pack a stringified value", () => { + const dataPacker = new PaddedDataPacker(32); + + const packed = dataPacker.pack({ foo: true }); + + expect(packed).toEqual("32|eyJmb28iOnRydWV9|000000000000"); + }); + + it("should pad to a multiple of the frame size", () => { + const dataPacker = new PaddedDataPacker(8); + + const packed = dataPacker.pack({ foo: true }); + expect(packed.length).toEqual(24); + }); + + it("should pad to a multiple of the frame size", () => { + const dataPacker = new PaddedDataPacker(8); + + const packed = dataPacker.pack({ foo: true }); + expect(packed.length).toEqual(24); + }); + }); + + describe("unpack", () => { + it("should unpack a value with the same frame size", () => { + const dataPacker = new PaddedDataPacker(32); + + const unpacked = dataPacker.unpack("32|eyJmb28iOnRydWV9|000000000000"); + + expect(unpacked).toEqual({ foo: true }); + }); + + it("should unpack a value with a different frame size", () => { + const dataPacker = new PaddedDataPacker(32); + + const unpacked = dataPacker.unpack("24|eyJmb28iOnRydWV9|0000"); + + expect(unpacked).toEqual({ foo: true }); + }); + + it("should unpack a value whose length is a multiple of the frame size", () => { + const dataPacker = new PaddedDataPacker(32); + + const unpacked = dataPacker.unpack("16|eyJmb28iOnRydWV9|000000000000"); + + expect(unpacked).toEqual({ foo: true }); + }); + + it("should throw an error when the frame size is missing", () => { + const dataPacker = new PaddedDataPacker(512); + const packed = `|eyJmb28iOnRydWV9|${"0".repeat(16)}`; + + expect(() => dataPacker.unpack(packed)).toThrow("missing frame size"); + }); + + it("should throw an error when the length is not a multiple of the frame size", () => { + const dataPacker = new PaddedDataPacker(16); + const packed = "16|eyJmb28iOnRydWV9|0"; + + expect(() => dataPacker.unpack(packed)).toThrow("invalid length"); + }); + + it("should throw an error when the padding divider is missing", () => { + const dataPacker = new PaddedDataPacker(16); + const packed = "16|eyJmb28iOnRydWV90000000000000"; + + expect(() => dataPacker.unpack(packed)).toThrow("missing json object"); + }); + + it("should throw an error when the padding contains a non-0 character", () => { + const dataPacker = new PaddedDataPacker(16); + const packed = "16|eyJmb28iOnRydWV9|000000000001"; + + expect(() => dataPacker.unpack(packed)).toThrow("invalid padding"); + }); + }); + + it("should unpack a packed JSON-literal value", () => { + const dataPacker = new PaddedDataPacker(8); + const input = { foo: true }; + + const packed = dataPacker.pack(input); + const unpacked = dataPacker.unpack(packed); + + expect(unpacked).toEqual(input); + }); + + it("should unpack a packed JSON-serializable value", () => { + const dataPacker = new PaddedDataPacker(8); + const input = { foo: new Date(100) }; + + const packed = dataPacker.pack(input); + const unpacked = dataPacker.unpack(packed); + + expect(unpacked).toEqual({ foo: "1970-01-01T00:00:00.100Z" }); + }); +}); diff --git a/libs/common/src/tools/generator/state/padded-data-packer.ts b/libs/common/src/tools/generator/state/padded-data-packer.ts new file mode 100644 index 00000000000..b55dfa378b7 --- /dev/null +++ b/libs/common/src/tools/generator/state/padded-data-packer.ts @@ -0,0 +1,94 @@ +import { Jsonify } from "type-fest"; + +import { Utils } from "../../../platform/misc/utils"; + +import { DataPacker as DataPackerAbstraction } from "./data-packer.abstraction"; + +const DATA_PACKING = Object.freeze({ + /** The character to use for padding. */ + padding: "0", + + /** The character dividing packed data. */ + divider: "|", + + /** A regular expression for detecting invalid padding. When the character + * changes, this should be updated to include the new padding pattern. + */ + hasInvalidPadding: /[^0]/, +}); + +/** A packing strategy that conceals the length of secret data by padding it + * to a multiple of the frame size. + * @example + * // packed === "24|e2Zvbzp0cnVlfQ==|0000" + * const packer = new SecretPacker(24); + * const packed = packer.pack({ foo: true }); + */ +export class PaddedDataPacker extends DataPackerAbstraction { + /** Instantiates the padded data packer + * @param frameSize The size of the dataframe used to pad encrypted values. + */ + constructor(private readonly frameSize: number) { + super(); + } + + /** + * Packs value into a string format that conceals the length by obscuring it + * with the frameSize. + * @see {@link DataPackerAbstraction.unpack} + */ + pack(value: Secret) { + // encode the value + const json = JSON.stringify(value); + const b64 = Utils.fromUtf8ToB64(json); + + // calculate packing metadata + const frameSize = JSON.stringify(this.frameSize); + const separatorLength = 2 * DATA_PACKING.divider.length; // there are 2 separators + const payloadLength = b64.length + frameSize.length + separatorLength; + const paddingLength = this.frameSize - (payloadLength % this.frameSize); + + // pack the data, thereby concealing its length + const padding = DATA_PACKING.padding.repeat(paddingLength); + const packed = `${frameSize}|${b64}|${padding}`; + + return packed; + } + + /** {@link DataPackerAbstraction.unpack} */ + unpack(secret: string): Jsonify { + // frame size is stored before the JSON payload in base 10 + const frameBreakpoint = secret.indexOf(DATA_PACKING.divider); + if (frameBreakpoint < 1) { + throw new Error("missing frame size"); + } + const frameSize = parseInt(secret.slice(0, frameBreakpoint), 10); + + // The decrypted string should be a multiple of the frame length + if (secret.length % frameSize > 0) { + throw new Error("invalid length"); + } + + // encoded data terminates with the divider, followed by the padding character + const jsonBreakpoint = secret.lastIndexOf(DATA_PACKING.divider); + if (jsonBreakpoint == frameBreakpoint) { + throw new Error("missing json object"); + } + const paddingBegins = jsonBreakpoint + 1; + + // If the padding contains invalid padding characters then the padding could be used + // as a side channel for arbitrary data. + if (secret.slice(paddingBegins).match(DATA_PACKING.hasInvalidPadding)) { + throw new Error("invalid padding"); + } + + // remove frame size and padding + const b64 = secret.substring(frameBreakpoint, paddingBegins); + + // unpack the stored data + const json = Utils.fromB64ToUtf8(b64); + const unpacked = JSON.parse(json); + + return unpacked; + } +} diff --git a/libs/common/src/tools/generator/state/secret-classifier.spec.ts b/libs/common/src/tools/generator/state/secret-classifier.spec.ts new file mode 100644 index 00000000000..819cd109233 --- /dev/null +++ b/libs/common/src/tools/generator/state/secret-classifier.spec.ts @@ -0,0 +1,177 @@ +import { SecretClassifier } from "./secret-classifier"; + +describe("SecretClassifier", () => { + describe("forSecret", () => { + it("classifies a property as secret by default", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean }>(); + + expect(classifier.disclosed).toEqual([]); + expect(classifier.excluded).toEqual([]); + }); + }); + + describe("disclose", () => { + it("adds a property to the disclosed list", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean }>(); + + const withDisclosedFoo = classifier.disclose("foo"); + + expect(withDisclosedFoo.disclosed).toEqual(["foo"]); + expect(withDisclosedFoo.excluded).toEqual([]); + }); + + it("chains calls with excluded", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>(); + + const withDisclosedFoo = classifier.disclose("foo").exclude("bar"); + + expect(withDisclosedFoo.disclosed).toEqual(["foo"]); + expect(withDisclosedFoo.excluded).toEqual(["bar"]); + }); + + it("returns a new classifier", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean }>(); + + const withDisclosedFoo = classifier.disclose("foo"); + + expect(withDisclosedFoo).not.toBe(classifier); + }); + }); + + describe("exclude", () => { + it("adds a property to the excluded list", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean }>(); + + const withExcludedFoo = classifier.exclude("foo"); + + expect(withExcludedFoo.disclosed).toEqual([]); + expect(withExcludedFoo.excluded).toEqual(["foo"]); + }); + + it("chains calls with disclose", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>(); + + const withExcludedFoo = classifier.exclude("foo").disclose("bar"); + + expect(withExcludedFoo.disclosed).toEqual(["bar"]); + expect(withExcludedFoo.excluded).toEqual(["foo"]); + }); + + it("returns a new classifier", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean }>(); + + const withExcludedFoo = classifier.exclude("foo"); + + expect(withExcludedFoo).not.toBe(classifier); + }); + }); + + describe("classify", () => { + it("partitions disclosed properties into the disclosed member", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().disclose( + "foo", + ); + + const classified = classifier.classify({ foo: true, bar: false }); + + expect(classified.disclosed).toEqual({ foo: true }); + }); + + it("deletes disclosed properties from the secret member", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().disclose( + "foo", + ); + + const classified = classifier.classify({ foo: true, bar: false }); + + expect(classified.secret).toEqual({ bar: false }); + }); + + it("deletes excluded properties from the secret member", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().exclude( + "foo", + ); + + const classified = classifier.classify({ foo: true, bar: false }); + + expect(classified.secret).toEqual({ bar: false }); + }); + + it("excludes excluded properties from the disclosed member", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().exclude( + "foo", + ); + + const classified = classifier.classify({ foo: true, bar: false }); + + expect(classified.disclosed).toEqual({}); + }); + + it("returns its input as the secret member", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean }>(); + const input = { foo: true }; + + const classified = classifier.classify(input); + + expect(classified.secret).toEqual(input); + }); + }); + + describe("declassify", () => { + it("merges disclosed and secret members", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().disclose( + "foo", + ); + + const declassified = classifier.declassify({ foo: true }, { bar: false }); + + expect(declassified).toEqual({ foo: true, bar: false }); + }); + + it("omits unknown disclosed members", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean }>().disclose("foo"); + + // `any` is required here because Typescript knows `bar` is not a disclosed member, + // but the feautre assumes the disclosed data bypassed the typechecker (e.g. someone + // is trying to clobber secret data.) + const declassified = classifier.declassify({ foo: true, bar: false } as any, {}); + + expect(declassified).toEqual({ foo: true }); + }); + + it("clobbers disclosed members with secret members", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().disclose( + "foo", + ); + + // `any` is required here because `declassify` knows `bar` is supposed to be public, + // but the feature assumes the secret data bypassed the typechecker (e.g. migrated data) + const declassified = classifier.declassify({ foo: true }, { foo: false, bar: false } as any); + + expect(declassified).toEqual({ foo: false, bar: false }); + }); + + it("omits excluded secret members", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().exclude( + "foo", + ); + + // `any` is required here because `declassify` knows `bar` isn't allowed, but the + // feature assumes the data bypassed the typechecker (e.g. omitted legacy data). + const declassified = classifier.declassify({}, { foo: false, bar: false } as any); + + expect(declassified).toEqual({ bar: false }); + }); + + it("returns a new object", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean }>(); + + const disclosed = {}; + const secret = { foo: false }; + const declassified = classifier.declassify(disclosed, secret); + + expect(declassified).not.toBe(disclosed); + expect(declassified).not.toBe(secret); + }); + }); +}); diff --git a/libs/common/src/tools/generator/state/secret-classifier.ts b/libs/common/src/tools/generator/state/secret-classifier.ts new file mode 100644 index 00000000000..232a31c686a --- /dev/null +++ b/libs/common/src/tools/generator/state/secret-classifier.ts @@ -0,0 +1,137 @@ +import { Jsonify } from "type-fest"; + +/** Classifies an object's JSON-serializable data by property into + * 3 categories: + * * Disclosed data MAY be stored in plaintext. + * * Excluded data MUST NOT be saved. + * * The remaining data is secret and MUST be stored using encryption. + * + * This type should not be used to classify functions. + * Data that cannot be serialized by JSON.stringify() should + * be excluded. + */ +export class SecretClassifier { + private constructor( + disclosed: readonly (keyof Jsonify<Disclosed> & keyof Jsonify<Plaintext>)[], + excluded: readonly (keyof Plaintext)[], + ) { + this.disclosed = disclosed; + this.excluded = excluded; + } + + /** lists the disclosed properties. */ + readonly disclosed: readonly (keyof Jsonify<Disclosed> & keyof Jsonify<Plaintext>)[]; + + /** lists the excluded properties. */ + readonly excluded: readonly (keyof Plaintext)[]; + + /** Creates a classifier where all properties are secret. + * @type {T} The type of secret being classified. + */ + static allSecret<T extends object>() { + const disclosed = Object.freeze([]); + const excluded = Object.freeze([]); + return new SecretClassifier<T, Record<keyof T, never>, T>(disclosed, excluded); + } + + /** Classify a property as disclosed. + * @type {PropertyName} Available secrets to disclose. + * @param disclose The property name to disclose. + * @returns a new classifier + */ + disclose<const PropertyName extends keyof Jsonify<Secret>>(disclose: PropertyName) { + // move the property from the secret type to the disclose type + type NewDisclosed = Disclosed | Record<PropertyName, Jsonify<Secret>[PropertyName]>; + type NewSecret = Omit<Secret, PropertyName>; + + // update the fluent interface + const newDisclosed = [...this.disclosed, disclose] as (keyof Jsonify<NewDisclosed> & + keyof Jsonify<Plaintext>)[]; + const classifier = new SecretClassifier<Plaintext, NewDisclosed, NewSecret>( + // since `NewDisclosed` is opaque to the type checker, it's necessary + // to assert the type of the array here. + Object.freeze(newDisclosed), + this.excluded, + ); + + return classifier; + } + + /** Classify a property as excluded. + * @type {PropertyName} Available secrets to exclude. + * @param exclude The property name to exclude. + * @returns a new classifier + */ + exclude<const PropertyName extends keyof Secret>(excludedPropertyName: PropertyName) { + // remove the property from the secret type + type NewConfidential = Omit<Secret, PropertyName>; + + // update the fluent interface + const newExcluded = [...this.excluded, excludedPropertyName] as (keyof Plaintext)[]; + const classifier = new SecretClassifier<Plaintext, Disclosed, NewConfidential>( + this.disclosed, + Object.freeze(newExcluded), + ); + + return classifier; + } + + /** Partitions `secret` into its disclosed properties and secret properties. + * @param secret The object to partition + * @returns an object that classifies secrets. + * The `disclosed` member is new and contains disclosed properties. + * The `secret` member aliases the secret parameter, with all + * disclosed and excluded properties deleted. + */ + classify(secret: Plaintext): { disclosed: Disclosed; secret: Secret } { + const copy = { ...secret }; + + for (const excludedProp of this.excluded) { + delete copy[excludedProp]; + } + + const disclosed: Record<PropertyKey, unknown> = {}; + for (const disclosedProp of this.disclosed) { + // disclosedProp is known to be a subset of the keys of `Plaintext`, so these + // type assertions are accurate. + // FIXME: prove it to the compiler + disclosed[disclosedProp] = copy[disclosedProp as unknown as keyof Plaintext]; + delete copy[disclosedProp as unknown as keyof Plaintext]; + } + + return { + disclosed: disclosed as Disclosed, + secret: copy as unknown as Secret, + }; + } + + /** Merges the properties of `secret` and `disclosed`. When `secret` and + * `disclosed` contain the same property, the `secret` property overrides + * the `disclosed` property. + * @param disclosed an object whose disclosed properties are merged into + * the output. Unknown properties are ignored. + * @param secret an objects whose properties are merged into the output. + * Excluded properties are ignored. Unknown properties are retained. + * @returns a new object containing the merged data. + */ + // Declassified data is always jsonified--the purpose of classifying it is to Jsonify it, + // which causes type conversions. + declassify(disclosed: Jsonify<Disclosed>, secret: Jsonify<Secret>): Jsonify<Plaintext> { + // removed unknown keys from `disclosed` to prevent any old edit + // of plaintext data from being laundered though declassification. + const cleaned = {} as Partial<Jsonify<Disclosed>>; + for (const disclosedProp of this.disclosed) { + cleaned[disclosedProp] = disclosed[disclosedProp]; + } + + // merge decrypted into cleaned so that secret data clobbers public data + const merged: any = Object.assign(cleaned, secret); + + // delete excluded props + for (const excludedProp of this.excluded) { + delete merged[excludedProp]; + } + + return merged as Jsonify<Plaintext>; + } +} diff --git a/libs/common/src/tools/generator/state/secret-state.spec.ts b/libs/common/src/tools/generator/state/secret-state.spec.ts new file mode 100644 index 00000000000..fa6288173a7 --- /dev/null +++ b/libs/common/src/tools/generator/state/secret-state.spec.ts @@ -0,0 +1,207 @@ +import { mock } from "jest-mock-extended"; +import { firstValueFrom, from } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { + FakeStateProvider, + makeEncString, + mockAccountServiceWith, + awaitAsync, +} from "../../../../spec"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { KeyDefinition, GENERATOR_DISK } from "../../../platform/state"; +import { UserId } from "../../../types/guid"; + +import { SecretState } from "./secret-state"; +import { UserEncryptor } from "./user-encryptor.abstraction"; + +type FooBar = { foo: boolean; bar: boolean; date?: Date }; +const FOOBAR_KEY = new KeyDefinition<FooBar>(GENERATOR_DISK, "fooBar", { + deserializer: (fb) => { + const result: FooBar = { foo: fb.foo, bar: fb.bar }; + + if (fb.date) { + result.date = new Date(fb.date); + } + + return result; + }, +}); +const SomeUser = "some user" as UserId; + +function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor<FooBar, Record<string, never>> { + // stores "encrypted values" so that they can be "decrypted" later + // while allowing the operations to be interleaved. + const encrypted = new Map<string, Jsonify<FooBar>>( + fooBar.map((fb) => [toKey(fb).encryptedString, toValue(fb)] as const), + ); + + const result = mock<UserEncryptor<FooBar, Record<string, never>>>({ + encrypt(value: FooBar, user: UserId) { + const encString = toKey(value); + encrypted.set(encString.encryptedString, toValue(value)); + return Promise.resolve({ secret: encString, disclosed: {} }); + }, + decrypt(secret: EncString, disclosed: Record<string, never>, userId: UserId) { + const decString = encrypted.get(toValue(secret.encryptedString)); + return Promise.resolve(decString); + }, + }); + + function toKey(value: FooBar) { + // `stringify` is only relevant for its uniqueness as a key + // to `encrypted`. + return makeEncString(JSON.stringify(value)); + } + + function toValue(value: any) { + // replace toJSON types with their round-trip equivalents + return JSON.parse(JSON.stringify(value)); + } + + // chromatic pops a false positive about missing `encrypt` and `decrypt` + // functions, so assert the type manually. + return result as unknown as UserEncryptor<FooBar, Record<string, never>>; +} + +async function fakeStateProvider() { + const accountService = mockAccountServiceWith(SomeUser); + const stateProvider = new FakeStateProvider(accountService); + return stateProvider; +} + +describe("UserEncryptor", () => { + describe("from", () => { + it("returns a state store", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + + const result = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + + expect(result).toBeInstanceOf(SecretState); + }); + }); + + describe("instance", () => { + it("gets a set value", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const value = { foo: true, bar: false }; + + await state.update(() => value); + await awaitAsync(); + const result = await firstValueFrom(state.state$); + + expect(result).toEqual(value); + }); + + it("round-trips json-serializable values", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const value = { foo: true, bar: true, date: new Date(1) }; + + await state.update(() => value); + await awaitAsync(); + const result = await firstValueFrom(state.state$); + + expect(result).toEqual(value); + }); + + it("gets the last set value", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const initialValue = { foo: true, bar: false }; + const replacementValue = { foo: false, bar: false }; + + await state.update(() => initialValue); + await state.update(() => replacementValue); + await awaitAsync(); + const result = await firstValueFrom(state.state$); + + expect(result).toEqual(replacementValue); + }); + + it("interprets shouldUpdate option", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const initialValue = { foo: true, bar: false }; + const replacementValue = { foo: false, bar: false }; + + await state.update(() => initialValue, { shouldUpdate: () => true }); + await state.update(() => replacementValue, { shouldUpdate: () => false }); + const result = await firstValueFrom(state.state$); + + expect(result).toEqual(initialValue); + }); + + it("sets the state to `null` when `update` returns `null`", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const value = { foo: true, bar: false }; + + await state.update(() => value); + await state.update(() => null); + await awaitAsync(); + const result = await firstValueFrom(state.state$); + + expect(result).toEqual(null); + }); + + it("sets the state to `null` when `update` returns `undefined`", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const value = { foo: true, bar: false }; + + await state.update(() => value); + await state.update(() => undefined); + await awaitAsync(); + const result = await firstValueFrom(state.state$); + + expect(result).toEqual(null); + }); + + it("sends rxjs observables into the shouldUpdate method", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const combinedWith$ = from([1]); + let combinedShouldUpdate = 0; + + await state.update((value) => value, { + shouldUpdate: (_, combined) => { + combinedShouldUpdate = combined; + return true; + }, + combineLatestWith: combinedWith$, + }); + + expect(combinedShouldUpdate).toEqual(1); + }); + + it("sends rxjs observables into the update method", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const combinedWith$ = from([1]); + let combinedUpdate = 0; + + await state.update( + (value, combined) => { + combinedUpdate = combined; + return value; + }, + { + combineLatestWith: combinedWith$, + }, + ); + + expect(combinedUpdate).toEqual(1); + }); + }); +}); diff --git a/libs/common/src/tools/generator/state/secret-state.ts b/libs/common/src/tools/generator/state/secret-state.ts new file mode 100644 index 00000000000..88d0d95eaf3 --- /dev/null +++ b/libs/common/src/tools/generator/state/secret-state.ts @@ -0,0 +1,192 @@ +import { Observable, concatMap, of, zip } from "rxjs"; +import { Jsonify } from "type-fest"; + +import { EncString } from "../../../platform/models/domain/enc-string"; +import { + DeriveDefinition, + DerivedState, + KeyDefinition, + SingleUserState, + StateProvider, + StateUpdateOptions, +} from "../../../platform/state"; +import { UserId } from "../../../types/guid"; + +import { UserEncryptor } from "./user-encryptor.abstraction"; + +/** Describes the structure of data stored by the SecretState's + * encrypted state. Notably, this interface ensures that `Disclosed` + * round trips through JSON serialization. + */ +type ClassifiedFormat<Disclosed> = { + /** Serialized {@link EncString} of the secret state's + * secret-level classified data. + */ + secret: string; + /** serialized representation of the secret state's + * disclosed-level classified data. + */ + disclosed: Jsonify<Disclosed>; +}; + +/** Stores account-specific secrets protected by a UserKeyEncryptor. + * + * @remarks This state store changes the structure of `Plaintext` during + * storage, and requires user keys to operate. It is incompatible with sync, + * which expects the disk storage format to be identical to the sync format. + * + * DO NOT USE THIS for synchronized data. + */ +export class SecretState<Plaintext extends object, Disclosed> { + // The constructor is private to avoid creating a circular dependency when + // wiring the derived and secret states together. + private constructor( + private readonly encryptor: UserEncryptor<Plaintext, Disclosed>, + private readonly encrypted: SingleUserState<ClassifiedFormat<Disclosed>>, + private readonly plaintext: DerivedState<Plaintext>, + ) { + this.state$ = plaintext.state$; + } + + /** Creates a secret state bound to an account encryptor. The account must be unlocked + * when this method is called. + * @param userId: the user to which the secret state is bound. + * @param key Converts between a declassified secret and its formal type. + * @param provider constructs state objects. + * @param encryptor protects `Secret` data. + * @throws when `key.stateDefinition` is backed by memory storage. + * @remarks Secrets are written to a secret store as a named tuple. Data classification is + * determined by the encryptor's classifier. Secret-classification data is jsonified, + * encrypted, and stored in a `secret` property. Disclosed-classification data is stored + * in a `disclosed` property. Omitted-classification data is not stored. + */ + static from<TFrom extends object, Disclosed>( + userId: UserId, + key: KeyDefinition<TFrom>, + provider: StateProvider, + encryptor: UserEncryptor<TFrom, Disclosed>, + ) { + // construct encrypted backing store while avoiding collisions between the derived key and the + // backing storage key. + const secretKey = new KeyDefinition<ClassifiedFormat<Disclosed>>(key.stateDefinition, key.key, { + cleanupDelayMs: key.cleanupDelayMs, + // FIXME: When the fakes run deserializers and serialization can be guaranteed through + // state providers, decode `jsonValue.secret` instead of it running in `derive`. + deserializer: (jsonValue) => jsonValue as ClassifiedFormat<Disclosed>, + }); + const encryptedState = provider.getUser(userId, secretKey); + + // construct plaintext store + const plaintextDefinition = DeriveDefinition.from<ClassifiedFormat<Disclosed>, TFrom>( + secretKey, + { + derive: async (from) => { + // fail fast if there's no value + if (from === null || from === undefined) { + return null; + } + + // otherwise forward the decrypted data to the caller's derive implementation + const secret = EncString.fromJSON(from.secret); + const decrypted = await encryptor.decrypt(secret, from.disclosed, encryptedState.userId); + const result = key.deserializer(decrypted) as TFrom; + + return result; + }, + // wire in the caller's deserializer for memory serialization + deserializer: key.deserializer, + // cache the decrypted data in memory + cleanupDelayMs: key.cleanupDelayMs, + }, + ); + const plaintextState = provider.getDerived(encryptedState.state$, plaintextDefinition, null); + + // wrap the encrypted and plaintext states in a `SecretState` facade + const secretState = new SecretState(encryptor, encryptedState, plaintextState); + return secretState; + } + + /** Observes changes to the decrypted secret state. The observer + * updates after the secret has been recorded to state storage. + * @returns `undefined` when the account is locked. + */ + readonly state$: Observable<Plaintext>; + + /** Updates the secret stored by this state. + * @param configureState a callback that returns an updated decrypted + * secret state. The callback receives the state's present value as its + * first argument and the dependencies listed in `options.combinedLatestWith` + * as its second argument. + * @param options configures how the update is applied. See {@link StateUpdateOptions}. + * @returns a promise that resolves with the updated value read from the state. + * The round-trip encrypts, decrypts, and deserializes the data, producing a new + * object. + * @remarks `configureState` must return a JSON-serializable object. + * If there are properties of your class which are not JSON-serializable, + * they can be lost when the secret state updates its backing store. + */ + async update<TCombine>( + configureState: (state: Plaintext, dependencies: TCombine) => Plaintext, + options: StateUpdateOptions<Plaintext, TCombine> = null, + ): Promise<Plaintext> { + // reactively grab the latest state from the caller. `zip` requires each + // observable has a value, so `combined$` provides a default if necessary. + const combined$ = options?.combineLatestWith ?? of(undefined); + const newState$ = zip(this.plaintext.state$, combined$).pipe( + concatMap(([currentState, combined]) => + this.prepareCryptoState( + currentState, + () => options?.shouldUpdate?.(currentState, combined) ?? true, + () => configureState(currentState, combined), + ), + ), + ); + + // update the backing store + let latestValue: Plaintext = null; + await this.encrypted.update((_, [, newStoredState]) => newStoredState, { + combineLatestWith: newState$, + shouldUpdate: (_, [shouldUpdate, , newState]) => { + // need to grab the latest value from the closure since the derived state + // could return its cached value, and this must be done in `shouldUpdate` + // because `configureState` may not run. + latestValue = newState; + return shouldUpdate; + }, + }); + + return latestValue; + } + + private async prepareCryptoState( + currentState: Plaintext, + shouldUpdate: () => boolean, + configureState: () => Plaintext, + ): Promise<[boolean, ClassifiedFormat<Disclosed>, Plaintext]> { + // determine whether an update is necessary + if (!shouldUpdate()) { + return [false, undefined, currentState]; + } + + // calculate the update + const newState = configureState(); + if (newState === null || newState === undefined) { + return [true, newState as any, newState]; + } + + // the encrypt format *is* the storage format, so if the shape of that data changes, + // this needs to map it explicitly for compatibility purposes. + const newStoredState = await this.encryptor.encrypt(newState, this.encrypted.userId); + + // the deserializer in the plaintextState's `derive` configuration always runs, but + // `encryptedState` is not guaranteed to serialize the data, so it's necessary to + // round-trip it proactively. This will cause some duplicate work in those situations + // where the backing store does deserialize the data. + // + // FIXME: Once there's a backing store configuration setting guaranteeing serialization, + // remove this code and configure the backing store as appropriate. + const serializedState = JSON.parse(JSON.stringify(newStoredState)); + + return [true, serializedState, newState]; + } +} diff --git a/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts b/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts new file mode 100644 index 00000000000..88a8bbe589c --- /dev/null +++ b/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts @@ -0,0 +1,42 @@ +import { Jsonify } from "type-fest"; + +import { EncString } from "../../../platform/models/domain/enc-string"; +import { UserId } from "../../../types/guid"; + +/** A classification strategy that protects a type's secrets with + * user-specific information. The specific kind of information is + * determined by the classification strategy. + */ +export abstract class UserEncryptor<State extends object, Disclosed> { + /** Protects secrets in `value` with a user-specific key. + * @param value the object to protect. This object is mutated during encryption. + * @param userId identifies the user-specific information used to protect + * the secret. + * @returns a promise that resolves to a tuple. The tuple's first property contains + * the encrypted secret and whose second property contains an object w/ disclosed + * properties. + * @throws If `value` is `null` or `undefined`, the promise rejects with an error. + */ + abstract encrypt( + value: State, + userId: UserId, + ): Promise<{ secret: EncString; disclosed: Disclosed }>; + + /** Combines protected secrets and disclosed data into a type that can be + * rehydrated into a domain object. + * @param secret an encrypted JSON payload containing State's secrets. + * @param disclosed a data object containing State's disclosed properties. + * @param userId identifies the user-specific information used to protect + * the secret. + * @returns a promise that resolves to the raw state. This state *is not* a + * class. It contains only data that can be round-tripped through JSON, + * and lacks members such as a prototype or bound functions. + * @throws If `secret` or `disclosed` is `null` or `undefined`, the promise + * rejects with an error. + */ + abstract decrypt( + secret: EncString, + disclosed: Jsonify<Disclosed>, + userId: UserId, + ): Promise<Jsonify<State>>; +} diff --git a/libs/common/src/tools/generator/state/user-key-encryptor.spec.ts b/libs/common/src/tools/generator/state/user-key-encryptor.spec.ts new file mode 100644 index 00000000000..e91cbe6b6b8 --- /dev/null +++ b/libs/common/src/tools/generator/state/user-key-encryptor.spec.ts @@ -0,0 +1,140 @@ +import { mock } from "jest-mock-extended"; + +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../../types/csprng"; +import { UserId } from "../../../types/guid"; +import { UserKey } from "../../../types/key"; + +import { DataPacker } from "./data-packer.abstraction"; +import { SecretClassifier } from "./secret-classifier"; +import { UserKeyEncryptor } from "./user-key-encryptor"; + +describe("UserKeyEncryptor", () => { + const encryptService = mock<EncryptService>(); + const keyService = mock<CryptoService>(); + const dataPacker = mock<DataPacker>(); + const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey; + const anyUserId = "foo" as UserId; + + beforeEach(() => { + // The UserKeyEncryptor is, in large part, a facade coordinating a handful of worker + // objects, so its tests focus on how data flows between components. The defaults rely + // on this property--that the facade treats its data like a opaque objects--to trace + // the data through several function calls. Should the encryptor interact with the + // objects themselves, it will break. + encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString)); + encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c as unknown as string)); + keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey)); + dataPacker.pack.mockImplementation((v) => v as string); + dataPacker.unpack.mockImplementation(<T>(v: string) => v as T); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("encrypt", () => { + it("should throw if value was not supplied", async () => { + const classifier = SecretClassifier.allSecret<object>(); + const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + + await expect(encryptor.encrypt(null, anyUserId)).rejects.toThrow( + "value cannot be null or undefined", + ); + await expect(encryptor.encrypt(undefined, anyUserId)).rejects.toThrow( + "value cannot be null or undefined", + ); + }); + + it("should throw if userId was not supplied", async () => { + const classifier = SecretClassifier.allSecret<object>(); + const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + + await expect(encryptor.encrypt({} as any, null)).rejects.toThrow( + "userId cannot be null or undefined", + ); + await expect(encryptor.encrypt({} as any, undefined)).rejects.toThrow( + "userId cannot be null or undefined", + ); + }); + + it("should classify data into a disclosed value and an encrypted packed value using the user's key", async () => { + const classifier = SecretClassifier.allSecret<object>(); + const classifierClassify = jest.spyOn(classifier, "classify"); + const disclosed = {} as any; + const secret = {} as any; + classifierClassify.mockReturnValue({ disclosed, secret }); + + const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + const value = { foo: true }; + + const result = await encryptor.encrypt(value, anyUserId); + + expect(classifierClassify).toHaveBeenCalledWith(value); + expect(keyService.getUserKey).toHaveBeenCalledWith(anyUserId); + expect(dataPacker.pack).toHaveBeenCalledWith(secret); + expect(encryptService.encrypt).toHaveBeenCalledWith(secret, userKey); + expect(result.secret).toBe(secret); + expect(result.disclosed).toBe(disclosed); + }); + }); + + describe("decrypt", () => { + it("should throw if secret was not supplied", async () => { + const classifier = SecretClassifier.allSecret<object>(); + const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + + await expect(encryptor.decrypt(null, {} as any, anyUserId)).rejects.toThrow( + "secret cannot be null or undefined", + ); + await expect(encryptor.decrypt(undefined, {} as any, anyUserId)).rejects.toThrow( + "secret cannot be null or undefined", + ); + }); + + it("should throw if disclosed was not supplied", async () => { + const classifier = SecretClassifier.allSecret<object>(); + const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + + await expect(encryptor.decrypt({} as any, null, anyUserId)).rejects.toThrow( + "disclosed cannot be null or undefined", + ); + await expect(encryptor.decrypt({} as any, undefined, anyUserId)).rejects.toThrow( + "disclosed cannot be null or undefined", + ); + }); + + it("should throw if userId was not supplied", async () => { + const classifier = SecretClassifier.allSecret<object>(); + const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + + await expect(encryptor.decrypt({} as any, {} as any, null)).rejects.toThrow( + "userId cannot be null or undefined", + ); + await expect(encryptor.decrypt({} as any, {} as any, undefined)).rejects.toThrow( + "userId cannot be null or undefined", + ); + }); + + it("should declassify a decrypted packed value using the user's key", async () => { + const classifier = SecretClassifier.allSecret<object>(); + const classifierDeclassify = jest.spyOn(classifier, "declassify"); + const declassified = {} as any; + classifierDeclassify.mockReturnValue(declassified); + const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + const secret = "encrypted" as any; + const disclosed = {} as any; + + const result = await encryptor.decrypt(secret, disclosed, anyUserId); + + expect(keyService.getUserKey).toHaveBeenCalledWith(anyUserId); + expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, userKey); + expect(dataPacker.unpack).toHaveBeenCalledWith(secret); + expect(classifierDeclassify).toHaveBeenCalledWith(disclosed, secret); + expect(result).toBe(declassified); + }); + }); +}); diff --git a/libs/common/src/tools/generator/state/user-key-encryptor.ts b/libs/common/src/tools/generator/state/user-key-encryptor.ts new file mode 100644 index 00000000000..8b78f5e229b --- /dev/null +++ b/libs/common/src/tools/generator/state/user-key-encryptor.ts @@ -0,0 +1,83 @@ +import { Jsonify } from "type-fest"; + +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { UserId } from "../../../types/guid"; + +import { DataPacker } from "./data-packer.abstraction"; +import { SecretClassifier } from "./secret-classifier"; +import { UserEncryptor } from "./user-encryptor.abstraction"; + +/** A classification strategy that protects a type's secrets by encrypting them + * with a `UserKey` + */ +export class UserKeyEncryptor<State extends object, Disclosed, Secret> extends UserEncryptor< + State, + Disclosed +> { + /** Instantiates the encryptor + * @param encryptService protects properties of `Secret`. + * @param keyService looks up the user key when protecting data. + * @param classifier partitions secrets and disclosed information. + * @param dataPacker packs and unpacks data classified as secrets. + */ + constructor( + private readonly encryptService: EncryptService, + private readonly keyService: CryptoService, + private readonly classifier: SecretClassifier<State, Disclosed, Secret>, + private readonly dataPacker: DataPacker, + ) { + super(); + } + + /** {@link UserEncryptor.encrypt} */ + async encrypt( + value: State, + userId: UserId, + ): Promise<{ secret: EncString; disclosed: Disclosed }> { + this.assertHasValue("value", value); + this.assertHasValue("userId", userId); + + const classified = this.classifier.classify(value); + let packed = this.dataPacker.pack(classified.secret); + + // encrypt the data and drop the key + let key = await this.keyService.getUserKey(userId); + const secret = await this.encryptService.encrypt(packed, key); + packed = null; + key = null; + + return { ...classified, secret }; + } + + /** {@link UserEncryptor.decrypt} */ + async decrypt( + secret: EncString, + disclosed: Jsonify<Disclosed>, + userId: UserId, + ): Promise<Jsonify<State>> { + this.assertHasValue("secret", secret); + this.assertHasValue("disclosed", disclosed); + this.assertHasValue("userId", userId); + + // decrypt the data and drop the key + let key = await this.keyService.getUserKey(userId); + let decrypted = await this.encryptService.decryptToUtf8(secret, key); + key = null; + + // reconstruct TFrom's data + const unpacked = this.dataPacker.unpack<Secret>(decrypted); + decrypted = null; + + const jsonValue = this.classifier.declassify(disclosed, unpacked); + + return jsonValue; + } + + private assertHasValue(name: string, value: any) { + if (value === undefined || value === null) { + throw new Error(`${name} cannot be null or undefined`); + } + } +} diff --git a/libs/common/src/tools/generator/username/options/utilities.spec.ts b/libs/common/src/tools/generator/username/options/utilities.spec.ts index 0152220ee44..904ac6dbfc6 100644 --- a/libs/common/src/tools/generator/username/options/utilities.spec.ts +++ b/libs/common/src/tools/generator/username/options/utilities.spec.ts @@ -2,20 +2,9 @@ * 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"; +import { UsernameGeneratorOptions } from "./generator-options"; +import { getForwarderOptions, falsyDefault, forAllForwarders } from "./utilities"; const TestOptions: UsernameGeneratorOptions = { type: "word", @@ -61,17 +50,6 @@ const TestOptions: UsernameGeneratorOptions = { }, }; -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.", () => { @@ -256,153 +234,4 @@ describe("Username Generation Options", () => { }); }); }); - - 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 index 46013fff90b..ba0c6c291f1 100644 --- a/libs/common/src/tools/generator/username/options/utilities.ts +++ b/libs/common/src/tools/generator/username/options/utilities.ts @@ -1,7 +1,4 @@ -import { EncryptService } from "../../../../platform/abstractions/encrypt.service"; -import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key"; - -import { DefaultOptions, Forwarders, SecretPadding } from "./constants"; +import { DefaultOptions, Forwarders } from "./constants"; import { ApiOptions, ForwarderId } from "./forwarder-options"; import { MaybeLeakedOptions, UsernameGeneratorOptions } from "./generator-options"; @@ -73,116 +70,3 @@ export function falsyDefault<T>(value: T, defaults: Partial<T>): T { 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 }; -}