1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-21 16:18:28 +01:00

[PM-6146] generator history (#8497)

* introduce `GeneratorHistoryService` abstraction
* implement generator history service with `LocalGeneratorHistoryService` 
* cache decrypted data using `ReplaySubject` instead of `DerivedState`
* move Jsonification from `DataPacker` to `SecretClassifier` because the classifier 
  is the only component that has full type information. The data packer still handles 
  stringification.
This commit is contained in:
✨ Audrey ✨ 2024-03-28 12:19:12 -04:00 committed by GitHub
parent 65353ae71d
commit df058ba399
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 691 additions and 212 deletions

View File

@ -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<GeneratedCredential | null>;
/** 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<GeneratedCredential | null>;
/** 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<GeneratedCredential[]>;
}

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export { GeneratorCategory } from "./options";
export { GeneratedCredential } from "./generated-credential";

View File

@ -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<EncryptService>();
const keyService = mock<CryptoService>();
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([]);
});
});
});

View File

@ -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<UserId, SingleUserState<GeneratedCredential[]>>();
/** {@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<keyof GeneratedCredential, never>,
GeneratedCredential
>(userId, GENERATOR_HISTORY, this.stateProvider, encryptor);
return state;
}
}

View File

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

View File

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

View File

@ -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<SelfHostedApiOptions>(
);
/** encrypted password generation history */
export const ENCRYPTED_HISTORY = new KeyDefinition<GeneratedPasswordHistory>(
export const GENERATOR_HISTORY = SecretKeyDefinition.array(
GENERATOR_DISK,
"passwordGeneratorHistory",
"localGeneratorHistory",
SecretClassifier.allSecret<GeneratedCredential>(),
{
deserializer: (value) => value,
deserializer: GeneratedCredential.fromJSON,
},
);

View File

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

View File

@ -9,7 +9,7 @@ export abstract class DataPacker {
* @param value is packed into the string
* @returns the packed string
*/
abstract pack<Data>(value: Data): string;
abstract pack<Data>(value: Jsonify<Data>): string;
/** Unpacks a string produced by pack.
* @param packedValue is the string to unpack

View File

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

View File

@ -37,7 +37,7 @@ export class PaddedDataPacker extends DataPackerAbstraction {
* with the frameSize.
* @see {@link DataPackerAbstraction.unpack}
*/
pack<Secret>(value: Secret) {
pack<Secret>(value: Jsonify<Secret>) {
// encode the value
const json = JSON.stringify(value);
const b64 = Utils.fromUtf8ToB64(json);

View File

@ -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", () => {

View File

@ -77,17 +77,19 @@ export class SecretClassifier<Plaintext extends object, Disclosed, Secret> {
}
/** 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>,
};
}

View File

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

View File

@ -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.

View File

@ -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();

View File

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

View File

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

View File

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

View File

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