From 05609a814c036a2fe34612f11df9dd38769c1bf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Thu, 21 Mar 2024 12:44:42 -0400 Subject: [PATCH] [PM-6847] `SecretState` array and record support (#8378) --- libs/common/src/platform/state/index.ts | 2 +- .../state/secret-key-definition.spec.ts | 186 ++++++++++++++++++ .../generator/state/secret-key-definition.ts | 92 +++++++++ .../generator/state/secret-state.spec.ts | 105 ++++++---- .../src/tools/generator/state/secret-state.ts | 125 +++++++----- .../state/user-encryptor.abstraction.ts | 18 +- .../state/user-key-encryptor.spec.ts | 69 ++----- .../generator/state/user-key-encryptor.ts | 33 +--- .../username/forwarder-generator-strategy.ts | 29 ++- 9 files changed, 485 insertions(+), 174 deletions(-) create mode 100644 libs/common/src/tools/generator/state/secret-key-definition.spec.ts create mode 100644 libs/common/src/tools/generator/state/secret-key-definition.ts diff --git a/libs/common/src/platform/state/index.ts b/libs/common/src/platform/state/index.ts index 79f5b4172f..dd14aaf329 100644 --- a/libs/common/src/platform/state/index.ts +++ b/libs/common/src/platform/state/index.ts @@ -6,7 +6,7 @@ export { StateProvider } from "./state.provider"; export { GlobalStateProvider } from "./global-state.provider"; export { ActiveUserState, SingleUserState, CombinedState } from "./user-state"; export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider"; -export { KeyDefinition } from "./key-definition"; +export { KeyDefinition, KeyDefinitionOptions } from "./key-definition"; export { StateUpdateOptions } from "./state-update-options"; export { UserKeyDefinition } from "./user-key-definition"; export { StateEventRunnerService } from "./state-event-runner.service"; diff --git a/libs/common/src/tools/generator/state/secret-key-definition.spec.ts b/libs/common/src/tools/generator/state/secret-key-definition.spec.ts new file mode 100644 index 0000000000..20bc1f5ee1 --- /dev/null +++ b/libs/common/src/tools/generator/state/secret-key-definition.spec.ts @@ -0,0 +1,186 @@ +import { GENERATOR_DISK } from "../../../platform/state"; + +import { SecretClassifier } from "./secret-classifier"; +import { SecretKeyDefinition } from "./secret-key-definition"; + +describe("SecretKeyDefinition", () => { + const classifier = SecretClassifier.allSecret<{ foo: boolean }>(); + const options = { deserializer: (v: any) => v }; + + describe("value", () => { + it("returns an initialized SecretKeyDefinition", () => { + const definition = SecretKeyDefinition.value(GENERATOR_DISK, "key", classifier, options); + + expect(definition).toBeInstanceOf(SecretKeyDefinition); + expect(definition.stateDefinition).toBe(GENERATOR_DISK); + expect(definition.key).toBe("key"); + expect(definition.classifier).toBe(classifier); + }); + + it("deconstruct returns an array with a single item", () => { + const definition = SecretKeyDefinition.value(GENERATOR_DISK, "key", classifier, options); + const value = { foo: true }; + + const result = definition.deconstruct(value); + + expect(result).toEqual([[null, value]]); + }); + + it("reconstruct returns the inner value", () => { + const definition = SecretKeyDefinition.value(GENERATOR_DISK, "key", classifier, options); + const value = { foo: true }; + + const result = definition.reconstruct([[null, value]]); + + expect(result).toBe(value); + }); + }); + + describe("array", () => { + it("returns an initialized SecretKeyDefinition", () => { + const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options); + + expect(definition).toBeInstanceOf(SecretKeyDefinition); + expect(definition.stateDefinition).toBe(GENERATOR_DISK); + expect(definition.key).toBe("key"); + expect(definition.classifier).toBe(classifier); + }); + + describe("deconstruct", () => { + it("over a 0-length array returns an empty array", () => { + const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options); + const value: { foo: boolean }[] = []; + + const result = definition.deconstruct(value); + + expect(result).toStrictEqual([]); + }); + + it("over a 1-length array returns a pair of indices and values", () => { + const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options); + const value = [{ foo: true }]; + + const result = definition.deconstruct(value); + + expect(result).toStrictEqual([[0, value[0]]]); + }); + + it("over an n-length array returns n pairs of indices and values", () => { + const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options); + const value = [{ foo: true }, { foo: false }]; + + const result = definition.deconstruct(value); + + expect(result).toStrictEqual([ + [0, value[0]], + [1, value[1]], + ]); + }); + }); + + describe("deconstruct", () => { + it("over a 0-length array of entries returns an empty array", () => { + const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options); + + const result = definition.reconstruct([]); + + expect(result).toStrictEqual([]); + }); + + it("over a 1-length array of entries returns a 1-length array", () => { + const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options); + const value = [{ foo: true }]; + + const result = definition.reconstruct([[0, value[0]]]); + + expect(result).toStrictEqual(value); + }); + + it("over an n-length array of entries returns an n-length array", () => { + const definition = SecretKeyDefinition.array(GENERATOR_DISK, "key", classifier, options); + const value = [{ foo: true }, { foo: false }]; + + const result = definition.reconstruct([ + [0, value[0]], + [1, value[1]], + ]); + + expect(result).toStrictEqual(value); + }); + }); + }); + + describe("record", () => { + it("returns an initialized SecretKeyDefinition", () => { + const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options); + + expect(definition).toBeInstanceOf(SecretKeyDefinition); + expect(definition.stateDefinition).toBe(GENERATOR_DISK); + expect(definition.key).toBe("key"); + expect(definition.classifier).toBe(classifier); + }); + + describe("deconstruct", () => { + it("over a 0-key record returns an empty array", () => { + const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options); + const value: Record = {}; + + const result = definition.deconstruct(value); + + expect(result).toStrictEqual([]); + }); + + it("over a 1-key record returns a pair of indices and values", () => { + const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options); + const value = { foo: { foo: true } }; + + const result = definition.deconstruct(value); + + expect(result).toStrictEqual([["foo", value["foo"]]]); + }); + + it("over an n-key record returns n pairs of indices and values", () => { + const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options); + const value = { foo: { foo: true }, bar: { foo: false } }; + + const result = definition.deconstruct(value); + + expect(result).toStrictEqual([ + ["foo", value["foo"]], + ["bar", value["bar"]], + ]); + }); + }); + + describe("deconstruct", () => { + it("over a 0-key record of entries returns an empty array", () => { + const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options); + + const result = definition.reconstruct([]); + + expect(result).toStrictEqual({}); + }); + + it("over a 1-key record of entries returns a 1-length record", () => { + const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options); + const value = { foo: { foo: true } }; + + const result = definition.reconstruct([["foo", value["foo"]]]); + + expect(result).toStrictEqual(value); + }); + + it("over an n-key record of entries returns an n-length record", () => { + const definition = SecretKeyDefinition.record(GENERATOR_DISK, "key", classifier, options); + const value = { foo: { foo: true }, bar: { foo: false } }; + + const result = definition.reconstruct([ + ["foo", value["foo"]], + ["bar", value["bar"]], + ]); + + expect(result).toStrictEqual(value); + }); + }); + }); +}); diff --git a/libs/common/src/tools/generator/state/secret-key-definition.ts b/libs/common/src/tools/generator/state/secret-key-definition.ts new file mode 100644 index 0000000000..eb139efbe7 --- /dev/null +++ b/libs/common/src/tools/generator/state/secret-key-definition.ts @@ -0,0 +1,92 @@ +import { KeyDefinitionOptions } from "../../../platform/state"; +// eslint-disable-next-line -- `StateDefinition` used as an argument +import { StateDefinition } from "../../../platform/state/state-definition"; +import { SecretClassifier } from "./secret-classifier"; + +/** Encryption and storage settings for data stored by a `SecretState`. + */ +export class SecretKeyDefinition { + private constructor( + readonly stateDefinition: StateDefinition, + readonly key: string, + readonly classifier: SecretClassifier, + readonly options: KeyDefinitionOptions, + // type erasure is necessary here because typescript doesn't support + // higher kinded types that generalize over collections. The invariants + // needed to make this typesafe are maintained by the static factories. + readonly deconstruct: (value: any) => [Id, any][], + readonly reconstruct: ([inners, ids]: (readonly [Id, any])[]) => Outer, + ) {} + + /** + * Define a secret state for a single value + * @param stateDefinition The domain of the secret's durable state. + * @param key Domain key that identifies the stored value. This key must not be reused + * in any capacity. + * @param classifier Partitions the value into encrypted, discarded, and public data. + * @param options Configures the operation of the secret state. + */ + static value( + stateDefinition: StateDefinition, + key: string, + classifier: SecretClassifier, + options: KeyDefinitionOptions, + ) { + return new SecretKeyDefinition( + stateDefinition, + key, + classifier, + options, + (value) => [[null, value]], + ([[, inner]]) => inner, + ); + } + + /** + * Define a secret state for an array of values. Each item is encrypted separately. + * @param stateDefinition The domain of the secret's durable state. + * @param key Domain key that identifies the stored items. This key must not be reused + * in any capacity. + * @param classifier Partitions each item into encrypted, discarded, and public data. + * @param options Configures the operation of the secret state. + */ + static array( + stateDefinition: StateDefinition, + key: string, + classifier: SecretClassifier, + options: KeyDefinitionOptions, + ) { + return new SecretKeyDefinition( + stateDefinition, + key, + classifier, + options, + (value) => value.map((v: any, id: number) => [id, v]), + (values) => values.map(([, v]) => v), + ); + } + + /** + * Define a secret state for a record. Each property is encrypted separately. + * @param stateDefinition The domain of the secret's durable state. + * @param key Domain key that identifies the stored properties. This key must not be reused + * in any capacity. + * @param classifier Partitions each property into encrypted, discarded, and public data. + * @param options Configures the operation of the secret state. + */ + static record( + stateDefinition: StateDefinition, + key: string, + classifier: SecretClassifier, + options: KeyDefinitionOptions, + ) { + return new SecretKeyDefinition, Id, Item, Disclosed, Secret>( + stateDefinition, + key, + classifier, + options, + (value) => Object.entries(value) as [Id, Item][], + (values) => Object.fromEntries(values) as Record, + ); + } +} diff --git a/libs/common/src/tools/generator/state/secret-state.spec.ts b/libs/common/src/tools/generator/state/secret-state.spec.ts index d4804fdb9b..364116fed3 100644 --- a/libs/common/src/tools/generator/state/secret-state.spec.ts +++ b/libs/common/src/tools/generator/state/secret-state.spec.ts @@ -9,15 +9,18 @@ import { awaitAsync, } from "../../../../spec"; import { EncString } from "../../../platform/models/domain/enc-string"; -import { KeyDefinition, GENERATOR_DISK } from "../../../platform/state"; +import { GENERATOR_DISK } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { SecretClassifier } from "./secret-classifier"; +import { SecretKeyDefinition } from "./secret-key-definition"; import { SecretState } from "./secret-state"; import { UserEncryptor } from "./user-encryptor.abstraction"; type FooBar = { foo: boolean; bar: boolean; date?: Date }; -const FOOBAR_KEY = new KeyDefinition(GENERATOR_DISK, "fooBar", { - deserializer: (fb) => { +const classifier = SecretClassifier.allSecret(); +const options: any = { + deserializer: (fb: FooBar) => { const result: FooBar = { foo: fb.foo, bar: fb.bar }; if (fb.date) { @@ -26,23 +29,27 @@ const FOOBAR_KEY = new KeyDefinition(GENERATOR_DISK, "fooBar", { return result; }, -}); +}; +const FOOBAR_VALUE = SecretKeyDefinition.value(GENERATOR_DISK, "fooBar", classifier, options); +const FOOBAR_ARRAY = SecretKeyDefinition.array(GENERATOR_DISK, "fooBar", classifier, options); +const FOOBAR_RECORD = SecretKeyDefinition.record(GENERATOR_DISK, "fooBar", classifier, options); + const SomeUser = "some user" as UserId; -function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor> { +function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor { // stores "encrypted values" so that they can be "decrypted" later // while allowing the operations to be interleaved. const encrypted = new Map>( fooBar.map((fb) => [toKey(fb).encryptedString, toValue(fb)] as const), ); - const result = mock>>({ + const result = mock>({ encrypt(value: FooBar, user: UserId) { const encString = toKey(value); encrypted.set(encString.encryptedString, toValue(value)); - return Promise.resolve({ secret: encString, disclosed: {} }); + return Promise.resolve(encString); }, - decrypt(secret: EncString, disclosed: Record, userId: UserId) { + decrypt(secret: EncString, userId: UserId) { const decString = encrypted.get(toValue(secret.encryptedString)); return Promise.resolve(decString); }, @@ -59,9 +66,9 @@ function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor>; + return result as unknown as UserEncryptor; } async function fakeStateProvider() { @@ -76,7 +83,7 @@ describe("UserEncryptor", () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const result = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const result = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); expect(result).toBeInstanceOf(SecretState); }); @@ -87,7 +94,7 @@ describe("UserEncryptor", () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); expect(state.userId).toEqual(SomeUser); }); @@ -95,7 +102,7 @@ describe("UserEncryptor", () => { it("state$ gets a set value", async () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const value = { foo: true, bar: false }; await state.update(() => value); @@ -105,10 +112,55 @@ describe("UserEncryptor", () => { expect(result).toEqual(value); }); + it("round-trips json-serializable values", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, 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("state$ gets a set array", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + const state = SecretState.from(SomeUser, FOOBAR_ARRAY, provider, encryptor); + const array = [ + { foo: true, bar: false, date: new Date(1) }, + { foo: false, bar: true }, + ]; + + await state.update(() => array); + await awaitAsync(); + const result = await firstValueFrom(state.state$); + + expect(result).toStrictEqual(array); + }); + + it("state$ gets a set record", async () => { + const provider = await fakeStateProvider(); + const encryptor = mockEncryptor(); + const state = SecretState.from(SomeUser, FOOBAR_RECORD, provider, encryptor); + const record = { + baz: { foo: true, bar: false, date: new Date(1) }, + biz: { foo: false, bar: true }, + }; + + await state.update(() => record); + await awaitAsync(); + const result = await firstValueFrom(state.state$); + + expect(result).toStrictEqual(record); + }); + it("combinedState$ gets a set value with the userId", async () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const value = { foo: true, bar: false }; await state.update(() => value); @@ -119,23 +171,10 @@ describe("UserEncryptor", () => { expect(userId).toEqual(SomeUser); }); - 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 state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const initialValue = { foo: true, bar: false }; const replacementValue = { foo: false, bar: false }; @@ -150,7 +189,7 @@ describe("UserEncryptor", () => { it("interprets shouldUpdate option", async () => { const provider = await fakeStateProvider(); const encryptor = mockEncryptor(); - const state = SecretState.from(SomeUser, FOOBAR_KEY, provider, encryptor); + const state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const initialValue = { foo: true, bar: false }; const replacementValue = { foo: false, bar: false }; @@ -164,7 +203,7 @@ describe("UserEncryptor", () => { 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 state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const value = { foo: true, bar: false }; await state.update(() => value); @@ -178,7 +217,7 @@ describe("UserEncryptor", () => { 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 state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const value = { foo: true, bar: false }; await state.update(() => value); @@ -192,7 +231,7 @@ describe("UserEncryptor", () => { 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 state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const combinedWith$ = from([1]); let combinedShouldUpdate = 0; @@ -210,7 +249,7 @@ describe("UserEncryptor", () => { 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 state = SecretState.from(SomeUser, FOOBAR_VALUE, provider, encryptor); const combinedWith$ = from([1]); let combinedUpdate = 0; diff --git a/libs/common/src/tools/generator/state/secret-state.ts b/libs/common/src/tools/generator/state/secret-state.ts index 62855c3280..a879b9f788 100644 --- a/libs/common/src/tools/generator/state/secret-state.ts +++ b/libs/common/src/tools/generator/state/secret-state.ts @@ -13,21 +13,27 @@ import { } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { SecretKeyDefinition } from "./secret-key-definition"; 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. + * round trips through JSON serialization. It also preserves the + * Id. + * @remarks Tuple representation chosen because it matches + * `Object.entries` format. */ -type ClassifiedFormat = { +type ClassifiedFormat = { + /** Identifies records. `null` when storing a `value` */ + readonly id: Id | null; /** Serialized {@link EncString} of the secret state's * secret-level classified data. */ - secret: string; + readonly secret: string; /** serialized representation of the secret state's * disclosed-level classified data. */ - disclosed: Jsonify; + readonly disclosed: Jsonify; }; /** Stores account-specific secrets protected by a UserKeyEncryptor. @@ -38,15 +44,16 @@ type ClassifiedFormat = { * * DO NOT USE THIS for synchronized data. */ -export class SecretState - implements SingleUserState<Plaintext> +export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret> + implements SingleUserState<Outer> { // 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>, + private readonly key: SecretKeyDefinition<Outer, Id, Plaintext, Disclosed, Secret>, + private readonly encryptor: UserEncryptor<Secret>, + private readonly encrypted: SingleUserState<ClassifiedFormat<Id, Disclosed>[]>, + private readonly plaintext: DerivedState<Outer>, ) { this.state$ = plaintext.state$; this.combinedState$ = plaintext.state$.pipe(map((state) => [this.encrypted.userId, state])); @@ -61,10 +68,10 @@ export class SecretState<Plaintext extends object, Disclosed> * updates after the secret has been recorded to state storage. * @returns `undefined` when the account is locked. */ - readonly state$: Observable<Plaintext>; + readonly state$: Observable<Outer>; /** {@link SingleUserState.combinedState$} */ - readonly combinedState$: Observable<CombinedState<Plaintext>>; + readonly combinedState$: Observable<CombinedState<Outer>>; /** Creates a secret state bound to an account encryptor. The account must be unlocked * when this method is called. @@ -78,24 +85,28 @@ export class SecretState<Plaintext extends object, Disclosed> * 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>( + static from<Outer, Id, TFrom extends object, Disclosed, Secret>( userId: UserId, - key: KeyDefinition<TFrom>, + key: SecretKeyDefinition<Outer, Id, TFrom, Disclosed, Secret>, provider: StateProvider, - encryptor: UserEncryptor<TFrom, Disclosed>, + encryptor: UserEncryptor<Secret>, ) { // 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 secretKey = new KeyDefinition<ClassifiedFormat<Id, Disclosed>[]>( + key.stateDefinition, + key.key, + { + cleanupDelayMs: key.options.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<Id, Disclosed>[], + }, + ); const encryptedState = provider.getUser(userId, secretKey); // construct plaintext store - const plaintextDefinition = DeriveDefinition.from<ClassifiedFormat<Disclosed>, TFrom>( + const plaintextDefinition = DeriveDefinition.from<ClassifiedFormat<Id, Disclosed>[], Outer>( secretKey, { derive: async (from) => { @@ -104,23 +115,38 @@ export class SecretState<Plaintext extends object, Disclosed> 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; + // decrypt each item + const decryptTasks = from.map(async ({ id, secret, disclosed }) => { + const encrypted = EncString.fromJSON(secret); + const decrypted = await encryptor.decrypt(encrypted, encryptedState.userId); + + const declassified = key.classifier.declassify(disclosed, decrypted); + const result = key.options.deserializer(declassified); + + return [id, result] as const; + }); + + // reconstruct expected type + const results = await Promise.all(decryptTasks); + const result = key.reconstruct(results); return result; }, // wire in the caller's deserializer for memory serialization - deserializer: key.deserializer, + deserializer: (d) => { + const items = key.deconstruct(d); + const results = items.map(([k, v]) => [k, key.options.deserializer(v)] as const); + const result = key.reconstruct(results); + return result; + }, // cache the decrypted data in memory - cleanupDelayMs: key.cleanupDelayMs, + cleanupDelayMs: key.options.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); + const secretState = new SecretState(key, encryptor, encryptedState, plaintextState); return secretState; } @@ -138,9 +164,9 @@ export class SecretState<Plaintext extends object, Disclosed> * 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> { + configureState: (state: Outer, dependencies: TCombine) => Outer, + options: StateUpdateOptions<Outer, TCombine> = null, + ): Promise<Outer> { // 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); @@ -155,7 +181,7 @@ export class SecretState<Plaintext extends object, Disclosed> ); // update the backing store - let latestValue: Plaintext = null; + let latestValue: Outer = null; await this.encrypted.update((_, [, newStoredState]) => newStoredState, { combineLatestWith: newState$, shouldUpdate: (_, [shouldUpdate, , newState]) => { @@ -171,10 +197,10 @@ export class SecretState<Plaintext extends object, Disclosed> } private async prepareCryptoState( - currentState: Plaintext, + currentState: Outer, shouldUpdate: () => boolean, - configureState: () => Plaintext, - ): Promise<[boolean, ClassifiedFormat<Disclosed>, Plaintext]> { + configureState: () => Outer, + ): Promise<[boolean, ClassifiedFormat<Id, Disclosed>[], Outer]> { // determine whether an update is necessary if (!shouldUpdate()) { return [false, undefined, currentState]; @@ -186,18 +212,25 @@ export class SecretState<Plaintext extends object, Disclosed> 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); + // convert the object to a list format so that all encrypt and decrypt + // operations are self-similar + const desconstructed = this.key.deconstruct(newState); - // 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)); + // encrypt each value individually + const encryptTasks = desconstructed.map(async ([id, state]) => { + const classified = this.key.classifier.classify(state); + const encrypted = await this.encryptor.encrypt(classified.secret, 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. + const serialized = JSON.parse( + JSON.stringify({ id, secret: encrypted, disclosed: classified.disclosed }), + ); + return serialized as ClassifiedFormat<Id, Disclosed>; + }); + const serializedState = await Promise.all(encryptTasks); 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 index 88a8bbe589..2009c6f255 100644 --- a/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts +++ b/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts @@ -7,9 +7,9 @@ import { UserId } from "../../../types/guid"; * user-specific information. The specific kind of information is * determined by the classification strategy. */ -export abstract class UserEncryptor<State extends object, Disclosed> { +export abstract class UserEncryptor<Secret> { /** Protects secrets in `value` with a user-specific key. - * @param value the object to protect. This object is mutated during encryption. + * @param secret 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 @@ -17,15 +17,11 @@ export abstract class UserEncryptor<State extends object, 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 }>; + abstract encrypt(secret: Secret, userId: UserId): Promise<EncString>; /** 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 secret an encrypted JSON payload containing encrypted secrets. * @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 @@ -34,9 +30,5 @@ export abstract class UserEncryptor<State extends object, Disclosed> { * @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>>; + abstract decrypt(secret: EncString, userId: UserId): Promise<Jsonify<Secret>>; } 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 index e91cbe6b6b..9289086986 100644 --- a/libs/common/src/tools/generator/state/user-key-encryptor.spec.ts +++ b/libs/common/src/tools/generator/state/user-key-encryptor.spec.ts @@ -9,7 +9,6 @@ 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", () => { @@ -38,20 +37,18 @@ describe("UserKeyEncryptor", () => { describe("encrypt", () => { it("should throw if value was not supplied", async () => { - const classifier = SecretClassifier.allSecret<object>(); - const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); await expect(encryptor.encrypt(null, anyUserId)).rejects.toThrow( - "value cannot be null or undefined", + "secret cannot be null or undefined", ); await expect(encryptor.encrypt(undefined, anyUserId)).rejects.toThrow( - "value cannot be null or undefined", + "secret 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); + const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); await expect(encryptor.encrypt({} as any, null)).rejects.toThrow( "userId cannot be null or undefined", @@ -61,80 +58,54 @@ describe("UserKeyEncryptor", () => { ); }); - 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); + it("should encrypt a packed value using the user's key", async () => { + const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); const value = { foo: true }; const result = await encryptor.encrypt(value, anyUserId); - expect(classifierClassify).toHaveBeenCalledWith(value); + // these are data flow expectations; the operations all all pass-through mocks 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); + expect(dataPacker.pack).toHaveBeenCalledWith(value); + expect(encryptService.encrypt).toHaveBeenCalledWith(value, userKey); + expect(result).toBe(value); }); }); describe("decrypt", () => { it("should throw if secret was not supplied", async () => { - const classifier = SecretClassifier.allSecret<object>(); - const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker); + const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); - await expect(encryptor.decrypt(null, {} as any, anyUserId)).rejects.toThrow( + await expect(encryptor.decrypt(null, anyUserId)).rejects.toThrow( "secret cannot be null or undefined", ); - await expect(encryptor.decrypt(undefined, {} as any, anyUserId)).rejects.toThrow( + await expect(encryptor.decrypt(undefined, 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); + const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); - await expect(encryptor.decrypt({} as any, {} as any, null)).rejects.toThrow( + await expect(encryptor.decrypt({} as any, null)).rejects.toThrow( "userId cannot be null or undefined", ); - await expect(encryptor.decrypt({} as any, {} as any, undefined)).rejects.toThrow( + await expect(encryptor.decrypt({} 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 encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); const secret = "encrypted" as any; - const disclosed = {} as any; - const result = await encryptor.decrypt(secret, disclosed, anyUserId); + const result = await encryptor.decrypt(secret, anyUserId); + // these are data flow expectations; the operations all all pass-through mocks 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); + expect(result).toBe(secret); }); }); }); diff --git a/libs/common/src/tools/generator/state/user-key-encryptor.ts b/libs/common/src/tools/generator/state/user-key-encryptor.ts index 8b78f5e229..22dbd41140 100644 --- a/libs/common/src/tools/generator/state/user-key-encryptor.ts +++ b/libs/common/src/tools/generator/state/user-key-encryptor.ts @@ -6,59 +6,44 @@ 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 -> { +export class UserKeyEncryptor<Secret> extends UserEncryptor<Secret> { /** 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); + async encrypt(secret: Secret, userId: UserId): Promise<EncString> { + this.assertHasValue("secret", secret); this.assertHasValue("userId", userId); - const classified = this.classifier.classify(value); - let packed = this.dataPacker.pack(classified.secret); + let packed = this.dataPacker.pack(secret); // encrypt the data and drop the key let key = await this.keyService.getUserKey(userId); - const secret = await this.encryptService.encrypt(packed, key); + const encrypted = await this.encryptService.encrypt(packed, key); packed = null; key = null; - return { ...classified, secret }; + return encrypted; } /** {@link UserEncryptor.decrypt} */ - async decrypt( - secret: EncString, - disclosed: Jsonify<Disclosed>, - userId: UserId, - ): Promise<Jsonify<State>> { + async decrypt(secret: EncString, userId: UserId): Promise<Jsonify<Secret>> { this.assertHasValue("secret", secret); - this.assertHasValue("disclosed", disclosed); this.assertHasValue("userId", userId); // decrypt the data and drop the key @@ -70,9 +55,7 @@ export class UserKeyEncryptor<State extends object, Disclosed, Secret> extends U const unpacked = this.dataPacker.unpack<Secret>(decrypted); decrypted = null; - const jsonValue = this.classifier.declassify(disclosed, unpacked); - - return jsonValue; + return unpacked; } private assertHasValue(name: string, value: any) { diff --git a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts index 554bbfca62..b0717695e0 100644 --- a/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts +++ b/libs/common/src/tools/generator/username/forwarder-generator-strategy.ts @@ -2,13 +2,14 @@ import { PolicyType } from "../../../admin-console/enums"; import { Policy } from "../../../admin-console/models/domain/policy"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; -import { KeyDefinition, StateProvider } from "../../../platform/state"; +import { KeyDefinition, SingleUserState, StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { GeneratorStrategy } from "../abstractions"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { NoPolicy } from "../no-policy"; import { PaddedDataPacker } from "../state/padded-data-packer"; import { SecretClassifier } from "../state/secret-classifier"; +import { SecretKeyDefinition } from "../state/secret-key-definition"; import { SecretState } from "../state/secret-state"; import { UserKeyEncryptor } from "../state/user-key-encryptor"; @@ -39,7 +40,7 @@ export abstract class ForwarderGeneratorStrategy< this.cache_ms = ONE_MINUTE; } - private durableStates = new Map<UserId, SecretState<Options, Record<string, never>>>(); + private durableStates = new Map<UserId, SingleUserState<Options>>(); /** {@link GeneratorStrategy.durableState} */ durableState = (userId: UserId) => { @@ -47,7 +48,24 @@ export abstract class ForwarderGeneratorStrategy< if (!state) { const encryptor = this.createEncryptor(); - state = SecretState.from(userId, this.key, this.stateProvider, encryptor); + // always exclude request properties + const classifier = SecretClassifier.allSecret<Options>().exclude("website"); + + // Derive the secret key definition + const key = SecretKeyDefinition.value(this.key.stateDefinition, this.key.key, classifier, { + deserializer: (d) => this.key.deserializer(d), + cleanupDelayMs: this.key.cleanupDelayMs, + }); + + // the type parameter is explicit because type inference fails for `Omit<Options, "website">` + state = SecretState.from< + Options, + void, + Options, + Record<keyof Options, never>, + Omit<Options, "website"> + >(userId, key, this.stateProvider, encryptor); + this.durableStates.set(userId, state); } @@ -55,12 +73,9 @@ export abstract class ForwarderGeneratorStrategy< }; private createEncryptor() { - // always exclude request properties - const classifier = SecretClassifier.allSecret<Options>().exclude("website"); - // construct the encryptor const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); - return new UserKeyEncryptor(this.encryptService, this.keyService, classifier, packer); + return new UserKeyEncryptor(this.encryptService, this.keyService, packer); } /** Determine where forwarder configuration is stored */