1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-03-02 03:41:09 +01:00

[PM-6847] SecretState array and record support (#8378)

This commit is contained in:
✨ Audrey ✨ 2024-03-21 12:44:42 -04:00 committed by GitHub
parent e7aad3829e
commit 05609a814c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 485 additions and 174 deletions

View File

@ -6,7 +6,7 @@ export { StateProvider } from "./state.provider";
export { GlobalStateProvider } from "./global-state.provider"; export { GlobalStateProvider } from "./global-state.provider";
export { ActiveUserState, SingleUserState, CombinedState } from "./user-state"; export { ActiveUserState, SingleUserState, CombinedState } from "./user-state";
export { ActiveUserStateProvider, SingleUserStateProvider } from "./user-state.provider"; 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 { StateUpdateOptions } from "./state-update-options";
export { UserKeyDefinition } from "./user-key-definition"; export { UserKeyDefinition } from "./user-key-definition";
export { StateEventRunnerService } from "./state-event-runner.service"; export { StateEventRunnerService } from "./state-event-runner.service";

View File

@ -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<string, { foo: boolean }> = {};
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);
});
});
});
});

View File

@ -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<Outer, Id, Inner extends object, Disclosed, Secret> {
private constructor(
readonly stateDefinition: StateDefinition,
readonly key: string,
readonly classifier: SecretClassifier<Inner, Disclosed, Secret>,
readonly options: KeyDefinitionOptions<Inner>,
// 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<Value extends object, Disclosed, Secret>(
stateDefinition: StateDefinition,
key: string,
classifier: SecretClassifier<Value, Disclosed, Secret>,
options: KeyDefinitionOptions<Value>,
) {
return new SecretKeyDefinition<Value, void, Value, Disclosed, Secret>(
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<Item extends object, Disclosed, Secret>(
stateDefinition: StateDefinition,
key: string,
classifier: SecretClassifier<Item, Disclosed, Secret>,
options: KeyDefinitionOptions<Item>,
) {
return new SecretKeyDefinition<Item[], number, Item, Disclosed, Secret>(
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<Item extends object, Disclosed, Secret, Id extends string | number>(
stateDefinition: StateDefinition,
key: string,
classifier: SecretClassifier<Item, Disclosed, Secret>,
options: KeyDefinitionOptions<Item>,
) {
return new SecretKeyDefinition<Record<Id, Item>, Id, Item, Disclosed, Secret>(
stateDefinition,
key,
classifier,
options,
(value) => Object.entries(value) as [Id, Item][],
(values) => Object.fromEntries(values) as Record<Id, Item>,
);
}
}

View File

@ -9,15 +9,18 @@ import {
awaitAsync, awaitAsync,
} from "../../../../spec"; } from "../../../../spec";
import { EncString } from "../../../platform/models/domain/enc-string"; 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 { UserId } from "../../../types/guid";
import { SecretClassifier } from "./secret-classifier";
import { SecretKeyDefinition } from "./secret-key-definition";
import { SecretState } from "./secret-state"; import { SecretState } from "./secret-state";
import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserEncryptor } from "./user-encryptor.abstraction";
type FooBar = { foo: boolean; bar: boolean; date?: Date }; type FooBar = { foo: boolean; bar: boolean; date?: Date };
const FOOBAR_KEY = new KeyDefinition<FooBar>(GENERATOR_DISK, "fooBar", { const classifier = SecretClassifier.allSecret<FooBar>();
deserializer: (fb) => { const options: any = {
deserializer: (fb: FooBar) => {
const result: FooBar = { foo: fb.foo, bar: fb.bar }; const result: FooBar = { foo: fb.foo, bar: fb.bar };
if (fb.date) { if (fb.date) {
@ -26,23 +29,27 @@ const FOOBAR_KEY = new KeyDefinition<FooBar>(GENERATOR_DISK, "fooBar", {
return result; 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; const SomeUser = "some user" as UserId;
function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor<FooBar, Record<string, never>> { function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor<FooBar> {
// stores "encrypted values" so that they can be "decrypted" later // stores "encrypted values" so that they can be "decrypted" later
// while allowing the operations to be interleaved. // while allowing the operations to be interleaved.
const encrypted = new Map<string, Jsonify<FooBar>>( const encrypted = new Map<string, Jsonify<FooBar>>(
fooBar.map((fb) => [toKey(fb).encryptedString, toValue(fb)] as const), fooBar.map((fb) => [toKey(fb).encryptedString, toValue(fb)] as const),
); );
const result = mock<UserEncryptor<FooBar, Record<string, never>>>({ const result = mock<UserEncryptor<FooBar>>({
encrypt(value: FooBar, user: UserId) { encrypt(value: FooBar, user: UserId) {
const encString = toKey(value); const encString = toKey(value);
encrypted.set(encString.encryptedString, toValue(value)); encrypted.set(encString.encryptedString, toValue(value));
return Promise.resolve({ secret: encString, disclosed: {} }); return Promise.resolve(encString);
}, },
decrypt(secret: EncString, disclosed: Record<string, never>, userId: UserId) { decrypt(secret: EncString, userId: UserId) {
const decString = encrypted.get(toValue(secret.encryptedString)); const decString = encrypted.get(toValue(secret.encryptedString));
return Promise.resolve(decString); return Promise.resolve(decString);
}, },
@ -59,9 +66,9 @@ function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor<FooBar, Record<stri
return JSON.parse(JSON.stringify(value)); return JSON.parse(JSON.stringify(value));
} }
// chromatic pops a false positive about missing `encrypt` and `decrypt` // typescript pops a false positive about missing `encrypt` and `decrypt`
// functions, so assert the type manually. // functions, so assert the type manually.
return result as unknown as UserEncryptor<FooBar, Record<string, never>>; return result as unknown as UserEncryptor<FooBar>;
} }
async function fakeStateProvider() { async function fakeStateProvider() {
@ -76,7 +83,7 @@ describe("UserEncryptor", () => {
const provider = await fakeStateProvider(); const provider = await fakeStateProvider();
const encryptor = mockEncryptor(); 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); expect(result).toBeInstanceOf(SecretState);
}); });
@ -87,7 +94,7 @@ describe("UserEncryptor", () => {
const provider = await fakeStateProvider(); const provider = await fakeStateProvider();
const encryptor = mockEncryptor(); 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); expect(state.userId).toEqual(SomeUser);
}); });
@ -95,7 +102,7 @@ describe("UserEncryptor", () => {
it("state$ gets a set value", async () => { it("state$ gets a set value", async () => {
const provider = await fakeStateProvider(); const provider = await fakeStateProvider();
const encryptor = mockEncryptor(); 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 }; const value = { foo: true, bar: false };
await state.update(() => value); await state.update(() => value);
@ -105,10 +112,55 @@ describe("UserEncryptor", () => {
expect(result).toEqual(value); 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 () => { it("combinedState$ gets a set value with the userId", async () => {
const provider = await fakeStateProvider(); const provider = await fakeStateProvider();
const encryptor = mockEncryptor(); 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 }; const value = { foo: true, bar: false };
await state.update(() => value); await state.update(() => value);
@ -119,23 +171,10 @@ describe("UserEncryptor", () => {
expect(userId).toEqual(SomeUser); 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 () => { it("gets the last set value", async () => {
const provider = await fakeStateProvider(); const provider = await fakeStateProvider();
const encryptor = mockEncryptor(); 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 initialValue = { foo: true, bar: false };
const replacementValue = { foo: false, bar: false }; const replacementValue = { foo: false, bar: false };
@ -150,7 +189,7 @@ describe("UserEncryptor", () => {
it("interprets shouldUpdate option", async () => { it("interprets shouldUpdate option", async () => {
const provider = await fakeStateProvider(); const provider = await fakeStateProvider();
const encryptor = mockEncryptor(); 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 initialValue = { foo: true, bar: false };
const replacementValue = { foo: false, 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 () => { it("sets the state to `null` when `update` returns `null`", async () => {
const provider = await fakeStateProvider(); const provider = await fakeStateProvider();
const encryptor = mockEncryptor(); 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 }; const value = { foo: true, bar: false };
await state.update(() => value); await state.update(() => value);
@ -178,7 +217,7 @@ describe("UserEncryptor", () => {
it("sets the state to `null` when `update` returns `undefined`", async () => { it("sets the state to `null` when `update` returns `undefined`", async () => {
const provider = await fakeStateProvider(); const provider = await fakeStateProvider();
const encryptor = mockEncryptor(); 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 }; const value = { foo: true, bar: false };
await state.update(() => value); await state.update(() => value);
@ -192,7 +231,7 @@ describe("UserEncryptor", () => {
it("sends rxjs observables into the shouldUpdate method", async () => { it("sends rxjs observables into the shouldUpdate method", async () => {
const provider = await fakeStateProvider(); const provider = await fakeStateProvider();
const encryptor = mockEncryptor(); 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]); const combinedWith$ = from([1]);
let combinedShouldUpdate = 0; let combinedShouldUpdate = 0;
@ -210,7 +249,7 @@ describe("UserEncryptor", () => {
it("sends rxjs observables into the update method", async () => { it("sends rxjs observables into the update method", async () => {
const provider = await fakeStateProvider(); const provider = await fakeStateProvider();
const encryptor = mockEncryptor(); 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]); const combinedWith$ = from([1]);
let combinedUpdate = 0; let combinedUpdate = 0;

View File

@ -13,21 +13,27 @@ import {
} from "../../../platform/state"; } from "../../../platform/state";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { SecretKeyDefinition } from "./secret-key-definition";
import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserEncryptor } from "./user-encryptor.abstraction";
/** Describes the structure of data stored by the SecretState's /** Describes the structure of data stored by the SecretState's
* encrypted state. Notably, this interface ensures that `Disclosed` * 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<Disclosed> = { type ClassifiedFormat<Id, Disclosed> = {
/** Identifies records. `null` when storing a `value` */
readonly id: Id | null;
/** Serialized {@link EncString} of the secret state's /** Serialized {@link EncString} of the secret state's
* secret-level classified data. * secret-level classified data.
*/ */
secret: string; readonly secret: string;
/** serialized representation of the secret state's /** serialized representation of the secret state's
* disclosed-level classified data. * disclosed-level classified data.
*/ */
disclosed: Jsonify<Disclosed>; readonly disclosed: Jsonify<Disclosed>;
}; };
/** Stores account-specific secrets protected by a UserKeyEncryptor. /** Stores account-specific secrets protected by a UserKeyEncryptor.
@ -38,15 +44,16 @@ type ClassifiedFormat<Disclosed> = {
* *
* DO NOT USE THIS for synchronized data. * DO NOT USE THIS for synchronized data.
*/ */
export class SecretState<Plaintext extends object, Disclosed> export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
implements SingleUserState<Plaintext> implements SingleUserState<Outer>
{ {
// The constructor is private to avoid creating a circular dependency when // The constructor is private to avoid creating a circular dependency when
// wiring the derived and secret states together. // wiring the derived and secret states together.
private constructor( private constructor(
private readonly encryptor: UserEncryptor<Plaintext, Disclosed>, private readonly key: SecretKeyDefinition<Outer, Id, Plaintext, Disclosed, Secret>,
private readonly encrypted: SingleUserState<ClassifiedFormat<Disclosed>>, private readonly encryptor: UserEncryptor<Secret>,
private readonly plaintext: DerivedState<Plaintext>, private readonly encrypted: SingleUserState<ClassifiedFormat<Id, Disclosed>[]>,
private readonly plaintext: DerivedState<Outer>,
) { ) {
this.state$ = plaintext.state$; this.state$ = plaintext.state$;
this.combinedState$ = plaintext.state$.pipe(map((state) => [this.encrypted.userId, 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. * updates after the secret has been recorded to state storage.
* @returns `undefined` when the account is locked. * @returns `undefined` when the account is locked.
*/ */
readonly state$: Observable<Plaintext>; readonly state$: Observable<Outer>;
/** {@link SingleUserState.combinedState$} */ /** {@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 /** Creates a secret state bound to an account encryptor. The account must be unlocked
* when this method is called. * 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 * encrypted, and stored in a `secret` property. Disclosed-classification data is stored
* in a `disclosed` property. Omitted-classification data is not 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, userId: UserId,
key: KeyDefinition<TFrom>, key: SecretKeyDefinition<Outer, Id, TFrom, Disclosed, Secret>,
provider: StateProvider, provider: StateProvider,
encryptor: UserEncryptor<TFrom, Disclosed>, encryptor: UserEncryptor<Secret>,
) { ) {
// construct encrypted backing store while avoiding collisions between the derived key and the // construct encrypted backing store while avoiding collisions between the derived key and the
// backing storage key. // backing storage key.
const secretKey = new KeyDefinition<ClassifiedFormat<Disclosed>>(key.stateDefinition, key.key, { const secretKey = new KeyDefinition<ClassifiedFormat<Id, Disclosed>[]>(
cleanupDelayMs: key.cleanupDelayMs, key.stateDefinition,
// FIXME: When the fakes run deserializers and serialization can be guaranteed through key.key,
// state providers, decode `jsonValue.secret` instead of it running in `derive`. {
deserializer: (jsonValue) => jsonValue as ClassifiedFormat<Disclosed>, 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); const encryptedState = provider.getUser(userId, secretKey);
// construct plaintext store // construct plaintext store
const plaintextDefinition = DeriveDefinition.from<ClassifiedFormat<Disclosed>, TFrom>( const plaintextDefinition = DeriveDefinition.from<ClassifiedFormat<Id, Disclosed>[], Outer>(
secretKey, secretKey,
{ {
derive: async (from) => { derive: async (from) => {
@ -104,23 +115,38 @@ export class SecretState<Plaintext extends object, Disclosed>
return null; return null;
} }
// otherwise forward the decrypted data to the caller's derive implementation // decrypt each item
const secret = EncString.fromJSON(from.secret); const decryptTasks = from.map(async ({ id, secret, disclosed }) => {
const decrypted = await encryptor.decrypt(secret, from.disclosed, encryptedState.userId); const encrypted = EncString.fromJSON(secret);
const result = key.deserializer(decrypted) as TFrom; 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; return result;
}, },
// wire in the caller's deserializer for memory serialization // 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 // cache the decrypted data in memory
cleanupDelayMs: key.cleanupDelayMs, cleanupDelayMs: key.options.cleanupDelayMs,
}, },
); );
const plaintextState = provider.getDerived(encryptedState.state$, plaintextDefinition, null); const plaintextState = provider.getDerived(encryptedState.state$, plaintextDefinition, null);
// wrap the encrypted and plaintext states in a `SecretState` facade // 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; 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. * they can be lost when the secret state updates its backing store.
*/ */
async update<TCombine>( async update<TCombine>(
configureState: (state: Plaintext, dependencies: TCombine) => Plaintext, configureState: (state: Outer, dependencies: TCombine) => Outer,
options: StateUpdateOptions<Plaintext, TCombine> = null, options: StateUpdateOptions<Outer, TCombine> = null,
): Promise<Plaintext> { ): Promise<Outer> {
// reactively grab the latest state from the caller. `zip` requires each // reactively grab the latest state from the caller. `zip` requires each
// observable has a value, so `combined$` provides a default if necessary. // observable has a value, so `combined$` provides a default if necessary.
const combined$ = options?.combineLatestWith ?? of(undefined); const combined$ = options?.combineLatestWith ?? of(undefined);
@ -155,7 +181,7 @@ export class SecretState<Plaintext extends object, Disclosed>
); );
// update the backing store // update the backing store
let latestValue: Plaintext = null; let latestValue: Outer = null;
await this.encrypted.update((_, [, newStoredState]) => newStoredState, { await this.encrypted.update((_, [, newStoredState]) => newStoredState, {
combineLatestWith: newState$, combineLatestWith: newState$,
shouldUpdate: (_, [shouldUpdate, , newState]) => { shouldUpdate: (_, [shouldUpdate, , newState]) => {
@ -171,10 +197,10 @@ export class SecretState<Plaintext extends object, Disclosed>
} }
private async prepareCryptoState( private async prepareCryptoState(
currentState: Plaintext, currentState: Outer,
shouldUpdate: () => boolean, shouldUpdate: () => boolean,
configureState: () => Plaintext, configureState: () => Outer,
): Promise<[boolean, ClassifiedFormat<Disclosed>, Plaintext]> { ): Promise<[boolean, ClassifiedFormat<Id, Disclosed>[], Outer]> {
// determine whether an update is necessary // determine whether an update is necessary
if (!shouldUpdate()) { if (!shouldUpdate()) {
return [false, undefined, currentState]; return [false, undefined, currentState];
@ -186,18 +212,25 @@ export class SecretState<Plaintext extends object, Disclosed>
return [true, newState as any, newState]; return [true, newState as any, newState];
} }
// the encrypt format *is* the storage format, so if the shape of that data changes, // convert the object to a list format so that all encrypt and decrypt
// this needs to map it explicitly for compatibility purposes. // operations are self-similar
const newStoredState = await this.encryptor.encrypt(newState, this.encrypted.userId); const desconstructed = this.key.deconstruct(newState);
// the deserializer in the plaintextState's `derive` configuration always runs, but // encrypt each value individually
// `encryptedState` is not guaranteed to serialize the data, so it's necessary to const encryptTasks = desconstructed.map(async ([id, state]) => {
// round-trip it proactively. This will cause some duplicate work in those situations const classified = this.key.classifier.classify(state);
// where the backing store does deserialize the data. const encrypted = await this.encryptor.encrypt(classified.secret, this.encrypted.userId);
//
// FIXME: Once there's a backing store configuration setting guaranteeing serialization, // the deserializer in the plaintextState's `derive` configuration always runs, but
// remove this code and configure the backing store as appropriate. // `encryptedState` is not guaranteed to serialize the data, so it's necessary to
const serializedState = JSON.parse(JSON.stringify(newStoredState)); // 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]; return [true, serializedState, newState];
} }

View File

@ -7,9 +7,9 @@ import { UserId } from "../../../types/guid";
* user-specific information. The specific kind of information is * user-specific information. The specific kind of information is
* determined by the classification strategy. * 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. /** 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 * @param userId identifies the user-specific information used to protect
* the secret. * the secret.
* @returns a promise that resolves to a tuple. The tuple's first property contains * @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. * properties.
* @throws If `value` is `null` or `undefined`, the promise rejects with an error. * @throws If `value` is `null` or `undefined`, the promise rejects with an error.
*/ */
abstract encrypt( abstract encrypt(secret: Secret, userId: UserId): Promise<EncString>;
value: State,
userId: UserId,
): Promise<{ secret: EncString; disclosed: Disclosed }>;
/** Combines protected secrets and disclosed data into a type that can be /** Combines protected secrets and disclosed data into a type that can be
* rehydrated into a domain object. * rehydrated into a domain object.
* @param secret an encrypted JSON payload containing State's secrets. * @param secret an encrypted JSON payload containing encrypted secrets.
* @param disclosed a data object containing State's disclosed properties.
* @param userId identifies the user-specific information used to protect * @param userId identifies the user-specific information used to protect
* the secret. * the secret.
* @returns a promise that resolves to the raw state. This state *is not* a * @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 * @throws If `secret` or `disclosed` is `null` or `undefined`, the promise
* rejects with an error. * rejects with an error.
*/ */
abstract decrypt( abstract decrypt(secret: EncString, userId: UserId): Promise<Jsonify<Secret>>;
secret: EncString,
disclosed: Jsonify<Disclosed>,
userId: UserId,
): Promise<Jsonify<State>>;
} }

View File

@ -9,7 +9,6 @@ import { UserId } from "../../../types/guid";
import { UserKey } from "../../../types/key"; import { UserKey } from "../../../types/key";
import { DataPacker } from "./data-packer.abstraction"; import { DataPacker } from "./data-packer.abstraction";
import { SecretClassifier } from "./secret-classifier";
import { UserKeyEncryptor } from "./user-key-encryptor"; import { UserKeyEncryptor } from "./user-key-encryptor";
describe("UserKeyEncryptor", () => { describe("UserKeyEncryptor", () => {
@ -38,20 +37,18 @@ describe("UserKeyEncryptor", () => {
describe("encrypt", () => { describe("encrypt", () => {
it("should throw if value was not supplied", async () => { it("should throw if value was not supplied", async () => {
const classifier = SecretClassifier.allSecret<object>(); const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
await expect(encryptor.encrypt(null, anyUserId)).rejects.toThrow( 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( 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 () => { it("should throw if userId was not supplied", async () => {
const classifier = SecretClassifier.allSecret<object>(); const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, dataPacker);
await expect(encryptor.encrypt({} as any, null)).rejects.toThrow( await expect(encryptor.encrypt({} as any, null)).rejects.toThrow(
"userId cannot be null or undefined", "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 () => { it("should encrypt a packed value using the user's key", async () => {
const classifier = SecretClassifier.allSecret<object>(); const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
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 value = { foo: true };
const result = await encryptor.encrypt(value, anyUserId); 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(keyService.getUserKey).toHaveBeenCalledWith(anyUserId);
expect(dataPacker.pack).toHaveBeenCalledWith(secret); expect(dataPacker.pack).toHaveBeenCalledWith(value);
expect(encryptService.encrypt).toHaveBeenCalledWith(secret, userKey); expect(encryptService.encrypt).toHaveBeenCalledWith(value, userKey);
expect(result.secret).toBe(secret); expect(result).toBe(value);
expect(result.disclosed).toBe(disclosed);
}); });
}); });
describe("decrypt", () => { describe("decrypt", () => {
it("should throw if secret was not supplied", async () => { it("should throw if secret was not supplied", async () => {
const classifier = SecretClassifier.allSecret<object>(); const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, 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", "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", "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 () => { it("should throw if userId was not supplied", async () => {
const classifier = SecretClassifier.allSecret<object>(); const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
const encryptor = new UserKeyEncryptor(encryptService, keyService, classifier, 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", "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", "userId cannot be null or undefined",
); );
}); });
it("should declassify a decrypted packed value using the user's key", async () => { it("should declassify a decrypted packed value using the user's key", async () => {
const classifier = SecretClassifier.allSecret<object>(); const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
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 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(keyService.getUserKey).toHaveBeenCalledWith(anyUserId);
expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, userKey); expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, userKey);
expect(dataPacker.unpack).toHaveBeenCalledWith(secret); expect(dataPacker.unpack).toHaveBeenCalledWith(secret);
expect(classifierDeclassify).toHaveBeenCalledWith(disclosed, secret); expect(result).toBe(secret);
expect(result).toBe(declassified);
}); });
}); });
}); });

View File

@ -6,59 +6,44 @@ import { EncString } from "../../../platform/models/domain/enc-string";
import { UserId } from "../../../types/guid"; import { UserId } from "../../../types/guid";
import { DataPacker } from "./data-packer.abstraction"; import { DataPacker } from "./data-packer.abstraction";
import { SecretClassifier } from "./secret-classifier";
import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserEncryptor } from "./user-encryptor.abstraction";
/** A classification strategy that protects a type's secrets by encrypting them /** A classification strategy that protects a type's secrets by encrypting them
* with a `UserKey` * with a `UserKey`
*/ */
export class UserKeyEncryptor<State extends object, Disclosed, Secret> extends UserEncryptor< export class UserKeyEncryptor<Secret> extends UserEncryptor<Secret> {
State,
Disclosed
> {
/** Instantiates the encryptor /** Instantiates the encryptor
* @param encryptService protects properties of `Secret`. * @param encryptService protects properties of `Secret`.
* @param keyService looks up the user key when protecting data. * @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. * @param dataPacker packs and unpacks data classified as secrets.
*/ */
constructor( constructor(
private readonly encryptService: EncryptService, private readonly encryptService: EncryptService,
private readonly keyService: CryptoService, private readonly keyService: CryptoService,
private readonly classifier: SecretClassifier<State, Disclosed, Secret>,
private readonly dataPacker: DataPacker, private readonly dataPacker: DataPacker,
) { ) {
super(); super();
} }
/** {@link UserEncryptor.encrypt} */ /** {@link UserEncryptor.encrypt} */
async encrypt( async encrypt(secret: Secret, userId: UserId): Promise<EncString> {
value: State, this.assertHasValue("secret", secret);
userId: UserId,
): Promise<{ secret: EncString; disclosed: Disclosed }> {
this.assertHasValue("value", value);
this.assertHasValue("userId", userId); this.assertHasValue("userId", userId);
const classified = this.classifier.classify(value); let packed = this.dataPacker.pack(secret);
let packed = this.dataPacker.pack(classified.secret);
// encrypt the data and drop the key // encrypt the data and drop the key
let key = await this.keyService.getUserKey(userId); 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; packed = null;
key = null; key = null;
return { ...classified, secret }; return encrypted;
} }
/** {@link UserEncryptor.decrypt} */ /** {@link UserEncryptor.decrypt} */
async decrypt( async decrypt(secret: EncString, userId: UserId): Promise<Jsonify<Secret>> {
secret: EncString,
disclosed: Jsonify<Disclosed>,
userId: UserId,
): Promise<Jsonify<State>> {
this.assertHasValue("secret", secret); this.assertHasValue("secret", secret);
this.assertHasValue("disclosed", disclosed);
this.assertHasValue("userId", userId); this.assertHasValue("userId", userId);
// decrypt the data and drop the key // 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); const unpacked = this.dataPacker.unpack<Secret>(decrypted);
decrypted = null; decrypted = null;
const jsonValue = this.classifier.declassify(disclosed, unpacked); return unpacked;
return jsonValue;
} }
private assertHasValue(name: string, value: any) { private assertHasValue(name: string, value: any) {

View File

@ -2,13 +2,14 @@ import { PolicyType } from "../../../admin-console/enums";
import { Policy } from "../../../admin-console/models/domain/policy"; import { Policy } from "../../../admin-console/models/domain/policy";
import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { CryptoService } from "../../../platform/abstractions/crypto.service";
import { EncryptService } from "../../../platform/abstractions/encrypt.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 { UserId } from "../../../types/guid";
import { GeneratorStrategy } from "../abstractions"; import { GeneratorStrategy } from "../abstractions";
import { DefaultPolicyEvaluator } from "../default-policy-evaluator"; import { DefaultPolicyEvaluator } from "../default-policy-evaluator";
import { NoPolicy } from "../no-policy"; import { NoPolicy } from "../no-policy";
import { PaddedDataPacker } from "../state/padded-data-packer"; import { PaddedDataPacker } from "../state/padded-data-packer";
import { SecretClassifier } from "../state/secret-classifier"; import { SecretClassifier } from "../state/secret-classifier";
import { SecretKeyDefinition } from "../state/secret-key-definition";
import { SecretState } from "../state/secret-state"; import { SecretState } from "../state/secret-state";
import { UserKeyEncryptor } from "../state/user-key-encryptor"; import { UserKeyEncryptor } from "../state/user-key-encryptor";
@ -39,7 +40,7 @@ export abstract class ForwarderGeneratorStrategy<
this.cache_ms = ONE_MINUTE; 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} */ /** {@link GeneratorStrategy.durableState} */
durableState = (userId: UserId) => { durableState = (userId: UserId) => {
@ -47,7 +48,24 @@ export abstract class ForwarderGeneratorStrategy<
if (!state) { if (!state) {
const encryptor = this.createEncryptor(); 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); this.durableStates.set(userId, state);
} }
@ -55,12 +73,9 @@ export abstract class ForwarderGeneratorStrategy<
}; };
private createEncryptor() { private createEncryptor() {
// always exclude request properties
const classifier = SecretClassifier.allSecret<Options>().exclude("website");
// construct the encryptor // construct the encryptor
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); 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 */ /** Determine where forwarder configuration is stored */