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:
parent
65353ae71d
commit
df058ba399
@ -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[]>;
|
||||
}
|
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
2
libs/common/src/tools/generator/history/index.ts
Normal file
2
libs/common/src/tools/generator/history/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { GeneratorCategory } from "./options";
|
||||
export { GeneratedCredential } from "./generated-credential";
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
||||
}
|
10
libs/common/src/tools/generator/history/options.ts
Normal file
10
libs/common/src/tools/generator/history/options.ts
Normal 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;
|
||||
};
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
19
libs/common/src/tools/generator/state/classified-format.ts
Normal file
19
libs/common/src/tools/generator/state/classified-format.ts
Normal 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>;
|
||||
};
|
@ -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
|
||||
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
|
@ -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", () => {
|
||||
|
@ -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>,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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.
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>>;
|
||||
}
|
||||
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
@ -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);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user