mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 14:23:32 +00: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:
@@ -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 {
|
import {
|
||||||
ENCRYPTED_HISTORY,
|
|
||||||
EFF_USERNAME_SETTINGS,
|
EFF_USERNAME_SETTINGS,
|
||||||
CATCHALL_SETTINGS,
|
CATCHALL_SETTINGS,
|
||||||
SUBADDRESS_SETTINGS,
|
SUBADDRESS_SETTINGS,
|
||||||
@@ -101,12 +100,4 @@ describe("Key definitions", () => {
|
|||||||
expect(result).toBe(value);
|
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 { GENERATOR_DISK, KeyDefinition } from "../../platform/state";
|
||||||
|
|
||||||
|
import { GeneratedCredential } from "./history/generated-credential";
|
||||||
import { PassphraseGenerationOptions } from "./passphrase/passphrase-generation-options";
|
import { PassphraseGenerationOptions } from "./passphrase/passphrase-generation-options";
|
||||||
import { GeneratedPasswordHistory } from "./password/generated-password-history";
|
|
||||||
import { PasswordGenerationOptions } from "./password/password-generation-options";
|
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 { CatchallGenerationOptions } from "./username/catchall-generator-options";
|
||||||
import { EffUsernameGenerationOptions } from "./username/eff-username-generator-options";
|
import { EffUsernameGenerationOptions } from "./username/eff-username-generator-options";
|
||||||
import {
|
import {
|
||||||
@@ -107,10 +109,11 @@ export const SIMPLE_LOGIN_FORWARDER = new KeyDefinition<SelfHostedApiOptions>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** encrypted password generation history */
|
/** encrypted password generation history */
|
||||||
export const ENCRYPTED_HISTORY = new KeyDefinition<GeneratedPasswordHistory>(
|
export const GENERATOR_HISTORY = SecretKeyDefinition.array(
|
||||||
GENERATOR_DISK,
|
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
|
* @param value is packed into the string
|
||||||
* @returns the packed string
|
* @returns the packed string
|
||||||
*/
|
*/
|
||||||
abstract pack<Data>(value: Data): string;
|
abstract pack<Data>(value: Jsonify<Data>): string;
|
||||||
|
|
||||||
/** Unpacks a string produced by pack.
|
/** Unpacks a string produced by pack.
|
||||||
* @param packedValue is the string to unpack
|
* @param packedValue is the string to unpack
|
||||||
|
|||||||
@@ -88,14 +88,4 @@ describe("UserKeyEncryptor", () => {
|
|||||||
|
|
||||||
expect(unpacked).toEqual(input);
|
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.
|
* with the frameSize.
|
||||||
* @see {@link DataPackerAbstraction.unpack}
|
* @see {@link DataPackerAbstraction.unpack}
|
||||||
*/
|
*/
|
||||||
pack<Secret>(value: Secret) {
|
pack<Secret>(value: Jsonify<Secret>) {
|
||||||
// encode the value
|
// encode the value
|
||||||
const json = JSON.stringify(value);
|
const json = JSON.stringify(value);
|
||||||
const b64 = Utils.fromUtf8ToB64(json);
|
const b64 = Utils.fromUtf8ToB64(json);
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ describe("SecretClassifier", () => {
|
|||||||
expect(classified.disclosed).toEqual({ foo: true });
|
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", () => {
|
it("deletes disclosed properties from the secret member", () => {
|
||||||
const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().disclose(
|
const classifier = SecretClassifier.allSecret<{ foo: boolean; bar: boolean }>().disclose(
|
||||||
"foo",
|
"foo",
|
||||||
@@ -106,15 +115,6 @@ describe("SecretClassifier", () => {
|
|||||||
|
|
||||||
expect(classified.disclosed).toEqual({});
|
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", () => {
|
describe("declassify", () => {
|
||||||
|
|||||||
@@ -77,17 +77,19 @@ export class SecretClassifier<Plaintext extends object, Disclosed, Secret> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Partitions `secret` into its disclosed properties and secret properties.
|
/** 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.
|
* @returns an object that classifies secrets.
|
||||||
* The `disclosed` member is new and contains disclosed properties.
|
* The `disclosed` member is new and contains disclosed properties.
|
||||||
* The `secret` member aliases the secret parameter, with all
|
* The `secret` member is a copy of the secret parameter, including its
|
||||||
* disclosed and excluded properties deleted.
|
* prototype, with all disclosed and excluded properties deleted.
|
||||||
*/
|
*/
|
||||||
classify(secret: Plaintext): { disclosed: Disclosed; secret: Secret } {
|
classify(value: Plaintext): { disclosed: Jsonify<Disclosed>; secret: Jsonify<Secret> } {
|
||||||
const copy = { ...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) {
|
for (const excludedProp of this.excluded) {
|
||||||
delete copy[excludedProp];
|
delete secret[excludedProp];
|
||||||
}
|
}
|
||||||
|
|
||||||
const disclosed: Record<PropertyKey, unknown> = {};
|
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
|
// disclosedProp is known to be a subset of the keys of `Plaintext`, so these
|
||||||
// type assertions are accurate.
|
// type assertions are accurate.
|
||||||
// FIXME: prove it to the compiler
|
// FIXME: prove it to the compiler
|
||||||
disclosed[disclosedProp] = copy[disclosedProp as unknown as keyof Plaintext];
|
disclosed[disclosedProp] = secret[disclosedProp as keyof Plaintext];
|
||||||
delete copy[disclosedProp as unknown as keyof Plaintext];
|
delete secret[disclosedProp as keyof Plaintext];
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
disclosed: disclosed as Disclosed,
|
disclosed: disclosed as Jsonify<Disclosed>,
|
||||||
secret: copy as unknown as Secret,
|
secret: secret as Jsonify<Secret>,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ describe("SecretKeyDefinition", () => {
|
|||||||
const classifier = SecretClassifier.allSecret<{ foo: boolean }>();
|
const classifier = SecretClassifier.allSecret<{ foo: boolean }>();
|
||||||
const options = { deserializer: (v: any) => v };
|
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", () => {
|
describe("value", () => {
|
||||||
it("returns an initialized SecretKeyDefinition", () => {
|
it("returns an initialized SecretKeyDefinition", () => {
|
||||||
const definition = SecretKeyDefinition.value(GENERATOR_DISK, "key", classifier, options);
|
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
|
// eslint-disable-next-line -- `StateDefinition` used as an argument
|
||||||
import { StateDefinition } from "../../../platform/state/state-definition";
|
import { StateDefinition } from "../../../platform/state/state-definition";
|
||||||
|
import { ClassifiedFormat } from "./classified-format";
|
||||||
import { SecretClassifier } from "./secret-classifier";
|
import { SecretClassifier } from "./secret-classifier";
|
||||||
|
|
||||||
/** Encryption and storage settings for data stored by a `SecretState`.
|
/** 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,
|
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
|
* Define a secret state for a single value
|
||||||
* @param stateDefinition The domain of the secret's durable state.
|
* @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;
|
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
|
// stores "encrypted values" so that they can be "decrypted" later
|
||||||
// while allowing the operations to be interleaved.
|
// while allowing the operations to be interleaved.
|
||||||
const encrypted = new Map<string, Jsonify<FooBar>>(
|
const encrypted = new Map<string, Jsonify<FooBar>>(
|
||||||
fooBar.map((fb) => [toKey(fb).encryptedString, toValue(fb)] as const),
|
fooBar.map((fb) => [toKey(fb as any).encryptedString, toValue(fb)] as const),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = mock<UserEncryptor<FooBar>>({
|
const result = mock<UserEncryptor>({
|
||||||
encrypt(value: FooBar, user: UserId) {
|
encrypt<T>(value: Jsonify<T>, user: UserId) {
|
||||||
const encString = toKey(value);
|
const encString = toKey(value as any);
|
||||||
encrypted.set(encString.encryptedString, toValue(value));
|
encrypted.set(encString.encryptedString, toValue(value));
|
||||||
return Promise.resolve(encString);
|
return Promise.resolve(encString);
|
||||||
},
|
},
|
||||||
decrypt(secret: EncString, userId: UserId) {
|
decrypt(secret: EncString, userId: UserId) {
|
||||||
const decString = encrypted.get(toValue(secret.encryptedString));
|
const decValue = encrypted.get(secret.encryptedString);
|
||||||
return Promise.resolve(decString);
|
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
|
// `stringify` is only relevant for its uniqueness as a key
|
||||||
// to `encrypted`.
|
// to `encrypted`.
|
||||||
return makeEncString(JSON.stringify(value));
|
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`
|
// typescript pops a false positive about missing `encrypt` and `decrypt`
|
||||||
// functions, so assert the type manually.
|
// functions, so assert the type manually.
|
||||||
return result as unknown as UserEncryptor<FooBar>;
|
return result as unknown as UserEncryptor;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fakeStateProvider() {
|
async function fakeStateProvider() {
|
||||||
@@ -77,7 +77,7 @@ async function fakeStateProvider() {
|
|||||||
return stateProvider;
|
return stateProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("UserEncryptor", () => {
|
describe("SecretState", () => {
|
||||||
describe("from", () => {
|
describe("from", () => {
|
||||||
it("returns a state store", async () => {
|
it("returns a state store", async () => {
|
||||||
const provider = await fakeStateProvider();
|
const provider = await fakeStateProvider();
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { Observable, concatMap, of, zip, map } from "rxjs";
|
import { Observable, map, concatMap, share, ReplaySubject, timer } from "rxjs";
|
||||||
import { Jsonify } from "type-fest";
|
|
||||||
|
|
||||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||||
import {
|
import {
|
||||||
DeriveDefinition,
|
|
||||||
DerivedState,
|
|
||||||
KeyDefinition,
|
|
||||||
SingleUserState,
|
SingleUserState,
|
||||||
StateProvider,
|
StateProvider,
|
||||||
StateUpdateOptions,
|
StateUpdateOptions,
|
||||||
@@ -13,28 +9,11 @@ import {
|
|||||||
} from "../../../platform/state";
|
} from "../../../platform/state";
|
||||||
import { UserId } from "../../../types/guid";
|
import { UserId } from "../../../types/guid";
|
||||||
|
|
||||||
|
import { ClassifiedFormat } from "./classified-format";
|
||||||
import { SecretKeyDefinition } from "./secret-key-definition";
|
import { SecretKeyDefinition } from "./secret-key-definition";
|
||||||
import { UserEncryptor } from "./user-encryptor.abstraction";
|
import { UserEncryptor } from "./user-encryptor.abstraction";
|
||||||
|
|
||||||
/** Describes the structure of data stored by the SecretState's
|
const ONE_MINUTE = 1000 * 60;
|
||||||
* 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>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Stores account-specific secrets protected by a UserKeyEncryptor.
|
/** 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.
|
// wiring the derived and secret states together.
|
||||||
private constructor(
|
private constructor(
|
||||||
private readonly key: SecretKeyDefinition<Outer, Id, Plaintext, Disclosed, Secret>,
|
private readonly key: SecretKeyDefinition<Outer, Id, Plaintext, Disclosed, Secret>,
|
||||||
private readonly encryptor: UserEncryptor<Secret>,
|
private readonly encryptor: UserEncryptor,
|
||||||
private readonly encrypted: SingleUserState<ClassifiedFormat<Id, Disclosed>[]>,
|
userId: UserId,
|
||||||
private readonly plaintext: DerivedState<Outer>,
|
provider: StateProvider,
|
||||||
) {
|
) {
|
||||||
this.state$ = plaintext.state$;
|
// construct the backing store
|
||||||
this.combinedState$ = plaintext.state$.pipe(map((state) => [this.encrypted.userId, state]));
|
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} */
|
/** {@link SingleUserState.userId} */
|
||||||
get userId() {
|
get userId() {
|
||||||
return this.encrypted.userId;
|
return this.encryptedState.userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Observes changes to the decrypted secret state. The observer
|
/** 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,
|
userId: UserId,
|
||||||
key: SecretKeyDefinition<Outer, Id, TFrom, Disclosed, Secret>,
|
key: SecretKeyDefinition<Outer, Id, TFrom, Disclosed, Secret>,
|
||||||
provider: StateProvider,
|
provider: StateProvider,
|
||||||
encryptor: UserEncryptor<Secret>,
|
encryptor: UserEncryptor,
|
||||||
) {
|
) {
|
||||||
// construct encrypted backing store while avoiding collisions between the derived key and the
|
const secretState = new SecretState(key, encryptor, userId, provider);
|
||||||
// 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);
|
|
||||||
return secretState;
|
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.
|
/** Updates the secret stored by this state.
|
||||||
* @param configureState a callback that returns an updated decrypted
|
* @param configureState a callback that returns an updated decrypted
|
||||||
* secret state. The callback receives the state's present value as its
|
* 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,
|
configureState: (state: Outer, dependencies: TCombine) => Outer,
|
||||||
options: StateUpdateOptions<Outer, TCombine> = null,
|
options: StateUpdateOptions<Outer, TCombine> = null,
|
||||||
): Promise<Outer> {
|
): Promise<Outer> {
|
||||||
// reactively grab the latest state from the caller. `zip` requires each
|
// read the backing store
|
||||||
// observable has a value, so `combined$` provides a default if necessary.
|
let latestClassified: ClassifiedFormat<Id, Disclosed>[];
|
||||||
const combined$ = options?.combineLatestWith ?? of(undefined);
|
let latestCombined: TCombine;
|
||||||
const newState$ = zip(this.plaintext.state$, combined$).pipe(
|
await this.encryptedState.update((c) => c, {
|
||||||
concatMap(([currentState, combined]) =>
|
shouldUpdate: (latest, combined) => {
|
||||||
this.prepareCryptoState(
|
latestClassified = latest;
|
||||||
currentState,
|
latestCombined = combined;
|
||||||
() => options?.shouldUpdate?.(currentState, combined) ?? true,
|
return false;
|
||||||
() => 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;
|
|
||||||
},
|
},
|
||||||
|
combineLatestWith: options?.combineLatestWith,
|
||||||
});
|
});
|
||||||
|
|
||||||
return latestValue;
|
// exit early if there's no update to apply
|
||||||
}
|
const latestDeclassified = await this.declassifyAll(latestClassified);
|
||||||
|
const shouldUpdate = options?.shouldUpdate?.(latestDeclassified, latestCombined) ?? true;
|
||||||
private async prepareCryptoState(
|
if (!shouldUpdate) {
|
||||||
currentState: Outer,
|
return latestDeclassified;
|
||||||
shouldUpdate: () => boolean,
|
|
||||||
configureState: () => Outer,
|
|
||||||
): Promise<[boolean, ClassifiedFormat<Id, Disclosed>[], Outer]> {
|
|
||||||
// determine whether an update is necessary
|
|
||||||
if (!shouldUpdate()) {
|
|
||||||
return [false, undefined, currentState];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculate the update
|
// apply the update
|
||||||
const newState = configureState();
|
const updatedDeclassified = configureState(latestDeclassified, latestCombined);
|
||||||
if (newState === null || newState === undefined) {
|
const updatedClassified = await this.classifyAll(updatedDeclassified);
|
||||||
return [true, newState as any, newState];
|
await this.encryptedState.update(() => updatedClassified);
|
||||||
}
|
|
||||||
|
|
||||||
// convert the object to a list format so that all encrypt and decrypt
|
return updatedDeclassified;
|
||||||
// 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];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { UserId } from "../../../types/guid";
|
|||||||
* user-specific information. The specific kind of information is
|
* user-specific information. The specific kind of information is
|
||||||
* determined by the classification strategy.
|
* determined by the classification strategy.
|
||||||
*/
|
*/
|
||||||
export abstract class UserEncryptor<Secret> {
|
export abstract class UserEncryptor {
|
||||||
/** Protects secrets in `value` with a user-specific key.
|
/** Protects secrets in `value` with a user-specific key.
|
||||||
* @param secret the object to protect. This object is mutated during encryption.
|
* @param secret the object to protect. This object is mutated during encryption.
|
||||||
* @param userId identifies the user-specific information used to protect
|
* @param userId identifies the user-specific information used to protect
|
||||||
@@ -17,7 +17,7 @@ export abstract class UserEncryptor<Secret> {
|
|||||||
* properties.
|
* properties.
|
||||||
* @throws If `value` is `null` or `undefined`, the promise rejects with an error.
|
* @throws If `value` is `null` or `undefined`, the promise rejects with an error.
|
||||||
*/
|
*/
|
||||||
abstract encrypt(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
|
/** Combines protected secrets and disclosed data into a type that can be
|
||||||
* rehydrated into a domain object.
|
* 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
|
* @throws If `secret` or `disclosed` is `null` or `undefined`, the promise
|
||||||
* rejects with an error.
|
* 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 () => {
|
it("should throw if value was not supplied", async () => {
|
||||||
const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
|
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",
|
"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",
|
"secret cannot be null or undefined",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -50,10 +50,10 @@ describe("UserKeyEncryptor", () => {
|
|||||||
it("should throw if userId was not supplied", async () => {
|
it("should throw if userId was not supplied", async () => {
|
||||||
const encryptor = new UserKeyEncryptor(encryptService, keyService, dataPacker);
|
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",
|
"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",
|
"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
|
/** A classification strategy that protects a type's secrets by encrypting them
|
||||||
* with a `UserKey`
|
* with a `UserKey`
|
||||||
*/
|
*/
|
||||||
export class UserKeyEncryptor<Secret> extends UserEncryptor<Secret> {
|
export class UserKeyEncryptor extends UserEncryptor {
|
||||||
/** Instantiates the encryptor
|
/** Instantiates the encryptor
|
||||||
* @param encryptService protects properties of `Secret`.
|
* @param encryptService protects properties of `Secret`.
|
||||||
* @param keyService looks up the user key when protecting data.
|
* @param keyService looks up the user key when protecting data.
|
||||||
@@ -26,7 +26,7 @@ export class UserKeyEncryptor<Secret> extends UserEncryptor<Secret> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** {@link UserEncryptor.encrypt} */
|
/** {@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("secret", secret);
|
||||||
this.assertHasValue("userId", userId);
|
this.assertHasValue("userId", userId);
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ export class UserKeyEncryptor<Secret> extends UserEncryptor<Secret> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** {@link UserEncryptor.decrypt} */
|
/** {@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("secret", secret);
|
||||||
this.assertHasValue("userId", userId);
|
this.assertHasValue("userId", userId);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user