diff --git a/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts b/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts new file mode 100644 index 00000000000..edda0dcb2ba --- /dev/null +++ b/libs/common/src/tools/generator/abstractions/generator-history.abstraction.ts @@ -0,0 +1,47 @@ +import { Observable } from "rxjs"; + +import { UserId } from "../../../types/guid"; +import { GeneratedCredential, GeneratorCategory } from "../history"; + +/** Tracks the history of password generations. + * Each user gets their own store. + */ +export abstract class GeneratorHistoryService { + /** Tracks a new credential. When an item with the same `credential` value + * is found, this method does nothing. When the total number of items exceeds + * {@link HistoryServiceOptions.maxTotal}, then the oldest items exceeding the total + * are deleted. + * @param userId identifies the user storing the credential. + * @param credential stored by the history service. + * @param date when the credential was generated. If this is omitted, then the generator + * uses the date the credential was added to the store instead. + * @returns a promise that completes with the added credential. If the credential + * wasn't added, then the promise completes with `null`. + * @remarks this service is not suitable for use with vault items/ciphers. It models only + * a history of an individually generated credential, while a vault item's history + * may contain several credentials that are better modelled as atomic versions of the + * vault item itself. + */ + track: ( + userId: UserId, + credential: string, + category: GeneratorCategory, + date?: Date, + ) => Promise; + + /** Removes a matching credential from the history service. + * @param userId identifies the user taking the credential. + * @param credential to match in the history service. + * @returns A promise that completes with the credential read. If the credential wasn't found, + * the promise completes with null. + * @remarks this can be used to extract an entry when a credential is stored in the vault. + */ + take: (userId: UserId, credential: string) => Promise; + + /** Lists all credentials for a user. + * @param userId identifies the user listing the credential. + * @remarks This field is eventually consistent with `track` and `take` operations. + * It is not guaranteed to immediately reflect those changes. + */ + credentials$: (userId: UserId) => Observable; +} diff --git a/libs/common/src/tools/generator/history/generated-credential.spec.ts b/libs/common/src/tools/generator/history/generated-credential.spec.ts new file mode 100644 index 00000000000..170030bad17 --- /dev/null +++ b/libs/common/src/tools/generator/history/generated-credential.spec.ts @@ -0,0 +1,58 @@ +import { GeneratorCategory, GeneratedCredential } from "./"; + +describe("GeneratedCredential", () => { + describe("constructor", () => { + it("assigns credential", () => { + const result = new GeneratedCredential("example", "passphrase", new Date(100)); + + expect(result.credential).toEqual("example"); + }); + + it("assigns category", () => { + const result = new GeneratedCredential("example", "passphrase", new Date(100)); + + expect(result.category).toEqual("passphrase"); + }); + + it("passes through date parameters", () => { + const result = new GeneratedCredential("example", "password", new Date(100)); + + expect(result.generationDate).toEqual(new Date(100)); + }); + + it("converts numeric dates to Dates", () => { + const result = new GeneratedCredential("example", "password", 100); + + expect(result.generationDate).toEqual(new Date(100)); + }); + }); + + it("toJSON converts from a credential into a JSON object", () => { + const credential = new GeneratedCredential("example", "password", new Date(100)); + + const result = credential.toJSON(); + + expect(result).toEqual({ + credential: "example", + category: "password" as GeneratorCategory, + generationDate: 100, + }); + }); + + it("fromJSON converts Json objects into credentials", () => { + const jsonValue = { + credential: "example", + category: "password" as GeneratorCategory, + generationDate: 100, + }; + + const result = GeneratedCredential.fromJSON(jsonValue); + + expect(result).toBeInstanceOf(GeneratedCredential); + expect(result).toEqual({ + credential: "example", + category: "password", + generationDate: new Date(100), + }); + }); +}); diff --git a/libs/common/src/tools/generator/history/generated-credential.ts b/libs/common/src/tools/generator/history/generated-credential.ts new file mode 100644 index 00000000000..59a9623bf7e --- /dev/null +++ b/libs/common/src/tools/generator/history/generated-credential.ts @@ -0,0 +1,47 @@ +import { Jsonify } from "type-fest"; + +import { GeneratorCategory } from "./options"; + +/** A credential generation result */ +export class GeneratedCredential { + /** + * Instantiates a generated credential + * @param credential The value of the generated credential (e.g. a password) + * @param category The kind of credential + * @param generationDate The date that the credential was generated. + * Numeric values should are interpreted using {@link Date.valueOf} + * semantics. + */ + constructor( + readonly credential: string, + readonly category: GeneratorCategory, + generationDate: Date | number, + ) { + if (typeof generationDate === "number") { + this.generationDate = new Date(generationDate); + } else { + this.generationDate = generationDate; + } + } + + /** The date that the credential was generated */ + generationDate: Date; + + /** Constructs a credential from its `toJSON` representation */ + static fromJSON(jsonValue: Jsonify) { + return new GeneratedCredential( + jsonValue.credential, + jsonValue.category, + jsonValue.generationDate, + ); + } + + /** Serializes a credential to a JSON-compatible object */ + toJSON() { + return { + credential: this.credential, + category: this.category, + generationDate: this.generationDate.valueOf(), + }; + } +} diff --git a/libs/common/src/tools/generator/history/index.ts b/libs/common/src/tools/generator/history/index.ts new file mode 100644 index 00000000000..1952a849af2 --- /dev/null +++ b/libs/common/src/tools/generator/history/index.ts @@ -0,0 +1,2 @@ +export { GeneratorCategory } from "./options"; +export { GeneratedCredential } from "./generated-credential"; diff --git a/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts b/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts new file mode 100644 index 00000000000..57dde51fc13 --- /dev/null +++ b/libs/common/src/tools/generator/history/local-generator-history.service.spec.ts @@ -0,0 +1,198 @@ +import { mock } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; + +import { FakeStateProvider, awaitAsync, mockAccountServiceWith } from "../../../../spec"; +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; +import { EncString } from "../../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../../types/csprng"; +import { UserId } from "../../../types/guid"; +import { UserKey } from "../../../types/key"; + +import { LocalGeneratorHistoryService } from "./local-generator-history.service"; + +const SomeUser = "SomeUser" as UserId; +const AnotherUser = "AnotherUser" as UserId; + +describe("LocalGeneratorHistoryService", () => { + const encryptService = mock(); + const keyService = mock(); + const userKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey; + + beforeEach(() => { + encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString)); + encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c.encryptedString)); + keyService.getUserKey.mockImplementation(() => Promise.resolve(userKey)); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("credential$", () => { + it("returns an empty list when no credentials are stored", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + const result = await firstValueFrom(history.credentials$(SomeUser)); + + expect(result).toEqual([]); + }); + }); + + describe("track", () => { + it("stores a password", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "example", "password"); + await awaitAsync(); + const [result] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(result).toMatchObject({ credential: "example", category: "password" }); + }); + + it("stores a passphrase", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "example", "passphrase"); + await awaitAsync(); + const [result] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(result).toMatchObject({ credential: "example", category: "passphrase" }); + }); + + it("stores a specific date when one is provided", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "example", "password", new Date(100)); + await awaitAsync(); + const [result] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(result).toEqual({ + credential: "example", + category: "password", + generationDate: new Date(100), + }); + }); + + it("skips storing a credential when it's already stored (ignores category)", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "example", "password"); + await history.track(SomeUser, "example", "passphrase"); + await awaitAsync(); + const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(firstResult).toMatchObject({ credential: "example", category: "password" }); + expect(secondResult).toBeUndefined(); + }); + + it("stores multiple credentials when the credential value is different", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + await history.track(SomeUser, "secondResult", "password"); + await history.track(SomeUser, "firstResult", "password"); + await awaitAsync(); + const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(firstResult).toMatchObject({ credential: "firstResult", category: "password" }); + expect(secondResult).toMatchObject({ credential: "secondResult", category: "password" }); + }); + + it("removes history items exceeding maxTotal configuration", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider, { + maxTotal: 1, + }); + + await history.track(SomeUser, "removed result", "password"); + await history.track(SomeUser, "example", "password"); + await awaitAsync(); + const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser)); + + expect(firstResult).toMatchObject({ credential: "example", category: "password" }); + expect(secondResult).toBeUndefined(); + }); + + it("stores history items in per-user collections", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider, { + maxTotal: 1, + }); + + await history.track(SomeUser, "some user example", "password"); + await history.track(AnotherUser, "another user example", "password"); + await awaitAsync(); + const [someFirstResult, someSecondResult] = await firstValueFrom( + history.credentials$(SomeUser), + ); + const [anotherFirstResult, anotherSecondResult] = await firstValueFrom( + history.credentials$(AnotherUser), + ); + + expect(someFirstResult).toMatchObject({ + credential: "some user example", + category: "password", + }); + expect(someSecondResult).toBeUndefined(); + expect(anotherFirstResult).toMatchObject({ + credential: "another user example", + category: "password", + }); + expect(anotherSecondResult).toBeUndefined(); + }); + }); + + describe("take", () => { + it("returns null when there are no credentials stored", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + + const result = await history.take(SomeUser, "example"); + + expect(result).toBeNull(); + }); + + it("returns null when the credential wasn't found", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + await history.track(SomeUser, "example", "password"); + + const result = await history.take(SomeUser, "not found"); + + expect(result).toBeNull(); + }); + + it("returns a matching credential", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + await history.track(SomeUser, "example", "password"); + + const result = await history.take(SomeUser, "example"); + + expect(result).toMatchObject({ + credential: "example", + category: "password", + }); + }); + + it("removes a matching credential", async () => { + const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser)); + const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider); + await history.track(SomeUser, "example", "password"); + + await history.take(SomeUser, "example"); + await awaitAsync(); + const results = await firstValueFrom(history.credentials$(SomeUser)); + + expect(results).toEqual([]); + }); + }); +}); diff --git a/libs/common/src/tools/generator/history/local-generator-history.service.ts b/libs/common/src/tools/generator/history/local-generator-history.service.ts new file mode 100644 index 00000000000..3a65890c50d --- /dev/null +++ b/libs/common/src/tools/generator/history/local-generator-history.service.ts @@ -0,0 +1,116 @@ +import { map } from "rxjs"; + +import { CryptoService } from "../../../platform/abstractions/crypto.service"; +import { EncryptService } from "../../../platform/abstractions/encrypt.service"; +import { SingleUserState, StateProvider } from "../../../platform/state"; +import { UserId } from "../../../types/guid"; +import { GeneratorHistoryService } from "../abstractions/generator-history.abstraction"; +import { GENERATOR_HISTORY } from "../key-definitions"; +import { PaddedDataPacker } from "../state/padded-data-packer"; +import { SecretState } from "../state/secret-state"; +import { UserKeyEncryptor } from "../state/user-key-encryptor"; + +import { GeneratedCredential } from "./generated-credential"; +import { GeneratorCategory, HistoryServiceOptions } from "./options"; + +const OPTIONS_FRAME_SIZE = 2048; + +/** Tracks the history of password generations local to a device. + * {@link GeneratorHistoryService} + */ +export class LocalGeneratorHistoryService extends GeneratorHistoryService { + constructor( + private readonly encryptService: EncryptService, + private readonly keyService: CryptoService, + private readonly stateProvider: StateProvider, + private readonly options: HistoryServiceOptions = { maxTotal: 100 }, + ) { + super(); + } + + private _credentialStates = new Map>(); + + /** {@link GeneratorHistoryService.track} */ + track = async (userId: UserId, credential: string, category: GeneratorCategory, date?: Date) => { + const state = this.getCredentialState(userId); + let result: GeneratedCredential = null; + + await state.update( + (credentials) => { + credentials = credentials ?? []; + + // add the result + result = new GeneratedCredential(credential, category, date ?? Date.now()); + credentials.unshift(result); + + // trim history + const removeAt = Math.max(0, this.options.maxTotal); + credentials.splice(removeAt, Infinity); + + return credentials; + }, + { + shouldUpdate: (credentials) => + credentials?.some((f) => f.credential !== credential) ?? true, + }, + ); + + return result; + }; + + /** {@link GeneratorHistoryService.take} */ + take = async (userId: UserId, credential: string) => { + const state = this.getCredentialState(userId); + let credentialIndex: number; + let result: GeneratedCredential = null; + + await state.update( + (credentials) => { + credentials = credentials ?? []; + + [result] = credentials.splice(credentialIndex, 1); + return credentials; + }, + { + shouldUpdate: (credentials) => { + credentialIndex = credentials?.findIndex((f) => f.credential === credential) ?? -1; + return credentialIndex >= 0; + }, + }, + ); + + return result; + }; + + /** {@link GeneratorHistoryService.credentials$} */ + credentials$ = (userId: UserId) => { + return this.getCredentialState(userId).state$.pipe(map((credentials) => credentials ?? [])); + }; + + private getCredentialState(userId: UserId) { + let state = this._credentialStates.get(userId); + + if (!state) { + state = this.createSecretState(userId); + this._credentialStates.set(userId, state); + } + + return state; + } + + private createSecretState(userId: UserId) { + // construct the encryptor + const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE); + const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer); + + const state = SecretState.from< + GeneratedCredential[], + number, + GeneratedCredential, + Record, + GeneratedCredential + >(userId, GENERATOR_HISTORY, this.stateProvider, encryptor); + + return state; + } +} diff --git a/libs/common/src/tools/generator/history/options.ts b/libs/common/src/tools/generator/history/options.ts new file mode 100644 index 00000000000..53716ec33ab --- /dev/null +++ b/libs/common/src/tools/generator/history/options.ts @@ -0,0 +1,10 @@ +/** Kinds of credentials that can be stored by the history service */ +export type GeneratorCategory = "password" | "passphrase"; + +/** Configuration options for the history service */ +export type HistoryServiceOptions = { + /** Total number of records retained across all types. + * @remarks Setting this to 0 or less disables history completely. + * */ + maxTotal: number; +}; diff --git a/libs/common/src/tools/generator/key-definition.spec.ts b/libs/common/src/tools/generator/key-definition.spec.ts index 735377a5ba2..f21767e77e8 100644 --- a/libs/common/src/tools/generator/key-definition.spec.ts +++ b/libs/common/src/tools/generator/key-definition.spec.ts @@ -1,5 +1,4 @@ import { - ENCRYPTED_HISTORY, EFF_USERNAME_SETTINGS, CATCHALL_SETTINGS, SUBADDRESS_SETTINGS, @@ -101,12 +100,4 @@ describe("Key definitions", () => { expect(result).toBe(value); }); }); - - describe("ENCRYPTED_HISTORY", () => { - it("should pass through deserialization", () => { - const value = {}; - const result = ENCRYPTED_HISTORY.deserializer(value as any); - expect(result).toBe(value); - }); - }); }); diff --git a/libs/common/src/tools/generator/key-definitions.ts b/libs/common/src/tools/generator/key-definitions.ts index bb7c4e8a086..d51af70f2e2 100644 --- a/libs/common/src/tools/generator/key-definitions.ts +++ b/libs/common/src/tools/generator/key-definitions.ts @@ -1,8 +1,10 @@ import { GENERATOR_DISK, KeyDefinition } from "../../platform/state"; +import { GeneratedCredential } from "./history/generated-credential"; import { PassphraseGenerationOptions } from "./passphrase/passphrase-generation-options"; -import { GeneratedPasswordHistory } from "./password/generated-password-history"; import { PasswordGenerationOptions } from "./password/password-generation-options"; +import { SecretClassifier } from "./state/secret-classifier"; +import { SecretKeyDefinition } from "./state/secret-key-definition"; import { CatchallGenerationOptions } from "./username/catchall-generator-options"; import { EffUsernameGenerationOptions } from "./username/eff-username-generator-options"; import { @@ -107,10 +109,11 @@ export const SIMPLE_LOGIN_FORWARDER = new KeyDefinition( ); /** encrypted password generation history */ -export const ENCRYPTED_HISTORY = new KeyDefinition( +export const GENERATOR_HISTORY = SecretKeyDefinition.array( GENERATOR_DISK, - "passwordGeneratorHistory", + "localGeneratorHistory", + SecretClassifier.allSecret(), { - deserializer: (value) => value, + deserializer: GeneratedCredential.fromJSON, }, ); diff --git a/libs/common/src/tools/generator/state/classified-format.ts b/libs/common/src/tools/generator/state/classified-format.ts new file mode 100644 index 00000000000..93147a0fb53 --- /dev/null +++ b/libs/common/src/tools/generator/state/classified-format.ts @@ -0,0 +1,19 @@ +import { Jsonify } from "type-fest"; + +/** Describes the structure of data stored by the SecretState's + * encrypted state. Notably, this interface ensures that `Disclosed` + * round trips through JSON serialization. It also preserves the + * Id. + */ +export 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. + */ + readonly secret: string; + /** serialized representation of the secret state's + * disclosed-level classified data. + */ + readonly disclosed: Jsonify; +}; diff --git a/libs/common/src/tools/generator/state/data-packer.abstraction.ts b/libs/common/src/tools/generator/state/data-packer.abstraction.ts index cb712e0fd9b..439fbb66c8c 100644 --- a/libs/common/src/tools/generator/state/data-packer.abstraction.ts +++ b/libs/common/src/tools/generator/state/data-packer.abstraction.ts @@ -9,7 +9,7 @@ export abstract class DataPacker { * @param value is packed into the string * @returns the packed string */ - abstract pack(value: Data): string; + abstract pack(value: Jsonify): string; /** Unpacks a string produced by pack. * @param packedValue is the string to unpack diff --git a/libs/common/src/tools/generator/state/padded-data-packer.spec.ts b/libs/common/src/tools/generator/state/padded-data-packer.spec.ts index 3cf225026b4..7e1d506988a 100644 --- a/libs/common/src/tools/generator/state/padded-data-packer.spec.ts +++ b/libs/common/src/tools/generator/state/padded-data-packer.spec.ts @@ -88,14 +88,4 @@ describe("UserKeyEncryptor", () => { expect(unpacked).toEqual(input); }); - - it("should unpack a packed JSON-serializable value", () => { - const dataPacker = new PaddedDataPacker(8); - const input = { foo: new Date(100) }; - - const packed = dataPacker.pack(input); - const unpacked = dataPacker.unpack(packed); - - expect(unpacked).toEqual({ foo: "1970-01-01T00:00:00.100Z" }); - }); }); diff --git a/libs/common/src/tools/generator/state/padded-data-packer.ts b/libs/common/src/tools/generator/state/padded-data-packer.ts index b55dfa378b7..e2f5058b217 100644 --- a/libs/common/src/tools/generator/state/padded-data-packer.ts +++ b/libs/common/src/tools/generator/state/padded-data-packer.ts @@ -37,7 +37,7 @@ export class PaddedDataPacker extends DataPackerAbstraction { * with the frameSize. * @see {@link DataPackerAbstraction.unpack} */ - pack(value: Secret) { + pack(value: Jsonify) { // encode the value const json = JSON.stringify(value); const b64 = Utils.fromUtf8ToB64(json); diff --git a/libs/common/src/tools/generator/state/secret-classifier.spec.ts b/libs/common/src/tools/generator/state/secret-classifier.spec.ts index 819cd109233..41dd8dc71bf 100644 --- a/libs/common/src/tools/generator/state/secret-classifier.spec.ts +++ b/libs/common/src/tools/generator/state/secret-classifier.spec.ts @@ -77,6 +77,15 @@ describe("SecretClassifier", () => { expect(classified.disclosed).toEqual({ foo: true }); }); + it("jsonifies its outputs", () => { + const classifier = SecretClassifier.allSecret<{ foo: Date; bar: Date }>().disclose("foo"); + + const classified = classifier.classify({ foo: new Date(100), bar: new Date(100) }); + + expect(classified.disclosed).toEqual({ foo: "1970-01-01T00:00:00.100Z" }); + expect(classified.secret).toEqual({ bar: "1970-01-01T00:00:00.100Z" }); + }); + it("deletes disclosed properties from the secret member", () => { const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().disclose( "foo", @@ -106,15 +115,6 @@ describe("SecretClassifier", () => { expect(classified.disclosed).toEqual({}); }); - - it("returns its input as the secret member", () => { - const classifier = SecretClassifier.allSecret<{ foo: boolean }>(); - const input = { foo: true }; - - const classified = classifier.classify(input); - - expect(classified.secret).toEqual(input); - }); }); describe("declassify", () => { diff --git a/libs/common/src/tools/generator/state/secret-classifier.ts b/libs/common/src/tools/generator/state/secret-classifier.ts index 232a31c686a..a26b01ac5dd 100644 --- a/libs/common/src/tools/generator/state/secret-classifier.ts +++ b/libs/common/src/tools/generator/state/secret-classifier.ts @@ -77,17 +77,19 @@ export class SecretClassifier { } /** Partitions `secret` into its disclosed properties and secret properties. - * @param secret The object to partition + * @param value The object to partition * @returns an object that classifies secrets. * The `disclosed` member is new and contains disclosed properties. - * The `secret` member aliases the secret parameter, with all - * disclosed and excluded properties deleted. + * The `secret` member is a copy of the secret parameter, including its + * prototype, with all disclosed and excluded properties deleted. */ - classify(secret: Plaintext): { disclosed: Disclosed; secret: Secret } { - const copy = { ...secret }; + classify(value: Plaintext): { disclosed: Jsonify<Disclosed>; secret: Jsonify<Secret> } { + // need to JSONify during classification because the prototype is almost guaranteed + // to be invalid when this method deletes arbitrary properties. + const secret = JSON.parse(JSON.stringify(value)) as Record<keyof Plaintext, unknown>; for (const excludedProp of this.excluded) { - delete copy[excludedProp]; + delete secret[excludedProp]; } const disclosed: Record<PropertyKey, unknown> = {}; @@ -95,13 +97,13 @@ export class SecretClassifier<Plaintext extends object, Disclosed, Secret> { // disclosedProp is known to be a subset of the keys of `Plaintext`, so these // type assertions are accurate. // FIXME: prove it to the compiler - disclosed[disclosedProp] = copy[disclosedProp as unknown as keyof Plaintext]; - delete copy[disclosedProp as unknown as keyof Plaintext]; + disclosed[disclosedProp] = secret[disclosedProp as keyof Plaintext]; + delete secret[disclosedProp as keyof Plaintext]; } return { - disclosed: disclosed as Disclosed, - secret: copy as unknown as Secret, + disclosed: disclosed as Jsonify<Disclosed>, + secret: secret as Jsonify<Secret>, }; } 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 index 20bc1f5ee17..7352631ff6c 100644 --- a/libs/common/src/tools/generator/state/secret-key-definition.spec.ts +++ b/libs/common/src/tools/generator/state/secret-key-definition.spec.ts @@ -7,6 +7,28 @@ describe("SecretKeyDefinition", () => { const classifier = SecretClassifier.allSecret<{ foo: boolean }>(); const options = { deserializer: (v: any) => v }; + it("toEncryptedStateKey returns a key", () => { + const expectedOptions = { + deserializer: (v: any) => v, + cleanupDelayMs: 100, + }; + const definition = SecretKeyDefinition.value( + GENERATOR_DISK, + "key", + classifier, + expectedOptions, + ); + const expectedDeserializerResult = {} as any; + + const result = definition.toEncryptedStateKey(); + const deserializerResult = result.deserializer(expectedDeserializerResult); + + expect(result.stateDefinition).toEqual(GENERATOR_DISK); + expect(result.key).toBe("key"); + expect(result.cleanupDelayMs).toBe(expectedOptions.cleanupDelayMs); + expect(deserializerResult).toBe(expectedDeserializerResult); + }); + describe("value", () => { it("returns an initialized SecretKeyDefinition", () => { const definition = SecretKeyDefinition.value(GENERATOR_DISK, "key", classifier, options); diff --git a/libs/common/src/tools/generator/state/secret-key-definition.ts b/libs/common/src/tools/generator/state/secret-key-definition.ts index eb139efbe7a..0de59be6244 100644 --- a/libs/common/src/tools/generator/state/secret-key-definition.ts +++ b/libs/common/src/tools/generator/state/secret-key-definition.ts @@ -1,6 +1,7 @@ -import { KeyDefinitionOptions } from "../../../platform/state"; +import { KeyDefinition, KeyDefinitionOptions } from "../../../platform/state"; // eslint-disable-next-line -- `StateDefinition` used as an argument import { StateDefinition } from "../../../platform/state/state-definition"; +import { ClassifiedFormat } from "./classified-format"; import { SecretClassifier } from "./secret-classifier"; /** Encryption and storage settings for data stored by a `SecretState`. @@ -18,6 +19,20 @@ export class SecretKeyDefinition<Outer, Id, Inner extends object, Disclosed, Sec readonly reconstruct: ([inners, ids]: (readonly [Id, any])[]) => Outer, ) {} + /** Converts the secret key to the `KeyDefinition` used for secret storage. */ + toEncryptedStateKey() { + const secretKey = new KeyDefinition<ClassifiedFormat<Id, Disclosed>[]>( + this.stateDefinition, + this.key, + { + cleanupDelayMs: this.options.cleanupDelayMs, + deserializer: (jsonValue) => jsonValue as ClassifiedFormat<Id, Disclosed>[], + }, + ); + + return secretKey; + } + /** * Define a secret state for a single value * @param stateDefinition The domain of the secret's durable state. 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 364116fed3b..1f5e14dde93 100644 --- a/libs/common/src/tools/generator/state/secret-state.spec.ts +++ b/libs/common/src/tools/generator/state/secret-state.spec.ts @@ -36,26 +36,26 @@ const FOOBAR_RECORD = SecretKeyDefinition.record(GENERATOR_DISK, "fooBar", class const SomeUser = "some user" as UserId; -function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor<FooBar> { +function mockEncryptor<T>(fooBar: T[] = []): UserEncryptor { // stores "encrypted values" so that they can be "decrypted" later // while allowing the operations to be interleaved. const encrypted = new Map<string, Jsonify<FooBar>>( - fooBar.map((fb) => [toKey(fb).encryptedString, toValue(fb)] as const), + fooBar.map((fb) => [toKey(fb as any).encryptedString, toValue(fb)] as const), ); - const result = mock<UserEncryptor<FooBar>>({ - encrypt(value: FooBar, user: UserId) { - const encString = toKey(value); + const result = mock<UserEncryptor>({ + encrypt<T>(value: Jsonify<T>, user: UserId) { + const encString = toKey(value as any); encrypted.set(encString.encryptedString, toValue(value)); return Promise.resolve(encString); }, decrypt(secret: EncString, userId: UserId) { - const decString = encrypted.get(toValue(secret.encryptedString)); - return Promise.resolve(decString); + const decValue = encrypted.get(secret.encryptedString); + return Promise.resolve(decValue as any); }, }); - function toKey(value: FooBar) { + function toKey(value: Jsonify<T>) { // `stringify` is only relevant for its uniqueness as a key // to `encrypted`. return makeEncString(JSON.stringify(value)); @@ -68,7 +68,7 @@ function mockEncryptor(fooBar: FooBar[] = []): UserEncryptor<FooBar> { // typescript pops a false positive about missing `encrypt` and `decrypt` // functions, so assert the type manually. - return result as unknown as UserEncryptor<FooBar>; + return result as unknown as UserEncryptor; } async function fakeStateProvider() { @@ -77,7 +77,7 @@ async function fakeStateProvider() { return stateProvider; } -describe("UserEncryptor", () => { +describe("SecretState", () => { describe("from", () => { it("returns a state store", async () => { const provider = await fakeStateProvider(); diff --git a/libs/common/src/tools/generator/state/secret-state.ts b/libs/common/src/tools/generator/state/secret-state.ts index a879b9f7889..dc4ee119a60 100644 --- a/libs/common/src/tools/generator/state/secret-state.ts +++ b/libs/common/src/tools/generator/state/secret-state.ts @@ -1,11 +1,7 @@ -import { Observable, concatMap, of, zip, map } from "rxjs"; -import { Jsonify } from "type-fest"; +import { Observable, map, concatMap, share, ReplaySubject, timer } from "rxjs"; import { EncString } from "../../../platform/models/domain/enc-string"; import { - DeriveDefinition, - DerivedState, - KeyDefinition, SingleUserState, StateProvider, StateUpdateOptions, @@ -13,28 +9,11 @@ import { } from "../../../platform/state"; import { UserId } from "../../../types/guid"; +import { ClassifiedFormat } from "./classified-format"; 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. It also preserves the - * Id. - * @remarks Tuple representation chosen because it matches - * `Object.entries` format. - */ -type ClassifiedFormat<Id, Disclosed> = { - /** Identifies records. `null` when storing a `value` */ - readonly id: Id | null; - /** Serialized {@link EncString} of the secret state's - * secret-level classified data. - */ - readonly secret: string; - /** serialized representation of the secret state's - * disclosed-level classified data. - */ - readonly disclosed: Jsonify<Disclosed>; -}; +const ONE_MINUTE = 1000 * 60; /** Stores account-specific secrets protected by a UserKeyEncryptor. * @@ -51,17 +30,34 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret> // wiring the derived and secret states together. private constructor( 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>, + private readonly encryptor: UserEncryptor, + userId: UserId, + provider: StateProvider, ) { - this.state$ = plaintext.state$; - this.combinedState$ = plaintext.state$.pipe(map((state) => [this.encrypted.userId, state])); + // construct the backing store + this.encryptedState = provider.getUser(userId, key.toEncryptedStateKey()); + + // cache plaintext + this.combinedState$ = this.encryptedState.combinedState$.pipe( + concatMap( + async ([userId, state]) => [userId, await this.declassifyAll(state)] as [UserId, Outer], + ), + share({ + connector: () => { + return new ReplaySubject<[UserId, Outer]>(1); + }, + resetOnRefCountZero: () => timer(key.options.cleanupDelayMs ?? ONE_MINUTE), + }), + ); + + this.state$ = this.combinedState$.pipe(map(([, state]) => state)); } + private readonly encryptedState: SingleUserState<ClassifiedFormat<Id, Disclosed>[]>; + /** {@link SingleUserState.userId} */ get userId() { - return this.encrypted.userId; + return this.encryptedState.userId; } /** Observes changes to the decrypted secret state. The observer @@ -89,67 +85,71 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret> userId: UserId, key: SecretKeyDefinition<Outer, Id, TFrom, Disclosed, Secret>, provider: StateProvider, - encryptor: UserEncryptor<Secret>, + encryptor: UserEncryptor, ) { - // construct encrypted backing store while avoiding collisions between the derived key and the - // backing storage key. - 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<Id, Disclosed>[], Outer>( - secretKey, - { - derive: async (from) => { - // fail fast if there's no value - if (from === null || from === undefined) { - return null; - } - - // 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: (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.options.cleanupDelayMs, - }, - ); - const plaintextState = provider.getDerived(encryptedState.state$, plaintextDefinition, null); - - // wrap the encrypted and plaintext states in a `SecretState` facade - const secretState = new SecretState(key, encryptor, encryptedState, plaintextState); + const secretState = new SecretState(key, encryptor, userId, provider); return secretState; } + private async declassifyItem({ id, secret, disclosed }: ClassifiedFormat<Id, Disclosed>) { + const encrypted = EncString.fromJSON(secret); + const decrypted = await this.encryptor.decrypt(encrypted, this.encryptedState.userId); + + const declassified = this.key.classifier.declassify(disclosed, decrypted); + const result = [id, this.key.options.deserializer(declassified)] as const; + + return result; + } + + private async declassifyAll(data: ClassifiedFormat<Id, Disclosed>[]) { + // fail fast if there's no value + if (data === null || data === undefined) { + return null; + } + + // decrypt each item + const decryptTasks = data.map(async (item) => this.declassifyItem(item)); + + // reconstruct expected type + const results = await Promise.all(decryptTasks); + const result = this.key.reconstruct(results); + + return result; + } + + private async classifyItem([id, item]: [Id, Plaintext]) { + const classified = this.key.classifier.classify(item); + const encrypted = await this.encryptor.encrypt(classified.secret, this.encryptedState.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 `encrypted` proactively. + const serialized = { + id, + secret: JSON.parse(JSON.stringify(encrypted)), + disclosed: classified.disclosed, + } as ClassifiedFormat<Id, Disclosed>; + + return serialized; + } + + private async classifyAll(data: Outer) { + // fail fast if there's no value + if (data === null || data === undefined) { + return null; + } + + // convert the object to a list format so that all encrypt and decrypt + // operations are self-similar + const desconstructed = this.key.deconstruct(data); + + // encrypt each value individually + const classifyTasks = desconstructed.map(async (item) => this.classifyItem(item)); + const classified = await Promise.all(classifyTasks); + + return classified; + } + /** Updates the secret stored by this state. * @param configureState a callback that returns an updated decrypted * secret state. The callback receives the state's present value as its @@ -167,71 +167,30 @@ export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret> 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); - const newState$ = zip(this.plaintext.state$, combined$).pipe( - concatMap(([currentState, combined]) => - this.prepareCryptoState( - currentState, - () => options?.shouldUpdate?.(currentState, combined) ?? true, - () => configureState(currentState, combined), - ), - ), - ); - - // update the backing store - let latestValue: Outer = null; - await this.encrypted.update((_, [, newStoredState]) => newStoredState, { - combineLatestWith: newState$, - shouldUpdate: (_, [shouldUpdate, , newState]) => { - // need to grab the latest value from the closure since the derived state - // could return its cached value, and this must be done in `shouldUpdate` - // because `configureState` may not run. - latestValue = newState; - return shouldUpdate; + // read the backing store + let latestClassified: ClassifiedFormat<Id, Disclosed>[]; + let latestCombined: TCombine; + await this.encryptedState.update((c) => c, { + shouldUpdate: (latest, combined) => { + latestClassified = latest; + latestCombined = combined; + return false; }, + combineLatestWith: options?.combineLatestWith, }); - return latestValue; - } - - private async prepareCryptoState( - currentState: Outer, - shouldUpdate: () => boolean, - configureState: () => Outer, - ): Promise<[boolean, ClassifiedFormat<Id, Disclosed>[], Outer]> { - // determine whether an update is necessary - if (!shouldUpdate()) { - return [false, undefined, currentState]; + // exit early if there's no update to apply + const latestDeclassified = await this.declassifyAll(latestClassified); + const shouldUpdate = options?.shouldUpdate?.(latestDeclassified, latestCombined) ?? true; + if (!shouldUpdate) { + return latestDeclassified; } - // calculate the update - const newState = configureState(); - if (newState === null || newState === undefined) { - return [true, newState as any, newState]; - } + // apply the update + const updatedDeclassified = configureState(latestDeclassified, latestCombined); + const updatedClassified = await this.classifyAll(updatedDeclassified); + await this.encryptedState.update(() => updatedClassified); - // convert the object to a list format so that all encrypt and decrypt - // operations are self-similar - const desconstructed = this.key.deconstruct(newState); - - // 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]; + return updatedDeclassified; } } 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 2009c6f255f..76539a0edf2 100644 --- a/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts +++ b/libs/common/src/tools/generator/state/user-encryptor.abstraction.ts @@ -7,7 +7,7 @@ import { UserId } from "../../../types/guid"; * user-specific information. The specific kind of information is * determined by the classification strategy. */ -export abstract class UserEncryptor<Secret> { +export abstract class UserEncryptor { /** Protects secrets in `value` with a user-specific key. * @param secret the object to protect. This object is mutated during encryption. * @param userId identifies the user-specific information used to protect @@ -17,7 +17,7 @@ export abstract class UserEncryptor<Secret> { * properties. * @throws If `value` is `null` or `undefined`, the promise rejects with an error. */ - abstract encrypt(secret: Secret, userId: UserId): Promise<EncString>; + abstract encrypt<Secret>(secret: Jsonify<Secret>, userId: UserId): Promise<EncString>; /** Combines protected secrets and disclosed data into a type that can be * rehydrated into a domain object. @@ -30,5 +30,5 @@ export abstract class UserEncryptor<Secret> { * @throws If `secret` or `disclosed` is `null` or `undefined`, the promise * rejects with an error. */ - abstract decrypt(secret: EncString, userId: UserId): Promise<Jsonify<Secret>>; + abstract decrypt<Secret>(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 9289086986b..072f7bd8f34 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 @@ -39,10 +39,10 @@ describe("UserKeyEncryptor", () => { it("should throw if value was not supplied", async () => { const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); - await expect(encryptor.encrypt(null, anyUserId)).rejects.toThrow( + await expect(encryptor.encrypt<Record<string, never>>(null, anyUserId)).rejects.toThrow( "secret cannot be null or undefined", ); - await expect(encryptor.encrypt(undefined, anyUserId)).rejects.toThrow( + await expect(encryptor.encrypt<Record<string, never>>(undefined, anyUserId)).rejects.toThrow( "secret cannot be null or undefined", ); }); @@ -50,10 +50,10 @@ describe("UserKeyEncryptor", () => { it("should throw if userId was not supplied", async () => { const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker); - await expect(encryptor.encrypt({} as any, null)).rejects.toThrow( + await expect(encryptor.encrypt({}, null)).rejects.toThrow( "userId cannot be null or undefined", ); - await expect(encryptor.encrypt({} as any, undefined)).rejects.toThrow( + await expect(encryptor.encrypt({}, undefined)).rejects.toThrow( "userId cannot be null or undefined", ); }); 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 22dbd41140b..27724d820d0 100644 --- a/libs/common/src/tools/generator/state/user-key-encryptor.ts +++ b/libs/common/src/tools/generator/state/user-key-encryptor.ts @@ -11,7 +11,7 @@ import { UserEncryptor } from "./user-encryptor.abstraction"; /** A classification strategy that protects a type's secrets by encrypting them * with a `UserKey` */ -export class UserKeyEncryptor<Secret> extends UserEncryptor<Secret> { +export class UserKeyEncryptor extends UserEncryptor { /** Instantiates the encryptor * @param encryptService protects properties of `Secret`. * @param keyService looks up the user key when protecting data. @@ -26,7 +26,7 @@ export class UserKeyEncryptor<Secret> extends UserEncryptor<Secret> { } /** {@link UserEncryptor.encrypt} */ - async encrypt(secret: Secret, userId: UserId): Promise<EncString> { + async encrypt<Secret>(secret: Jsonify<Secret>, userId: UserId): Promise<EncString> { this.assertHasValue("secret", secret); this.assertHasValue("userId", userId); @@ -42,7 +42,7 @@ export class UserKeyEncryptor<Secret> extends UserEncryptor<Secret> { } /** {@link UserEncryptor.decrypt} */ - async decrypt(secret: EncString, userId: UserId): Promise<Jsonify<Secret>> { + async decrypt<Secret>(secret: EncString, userId: UserId): Promise<Jsonify<Secret>> { this.assertHasValue("secret", secret); this.assertHasValue("userId", userId);