1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-22 19:23:52 +00:00

[PM-9008] factor generator-extensions into separate libraries (#9724)

This commit is contained in:
✨ Audrey ✨
2024-06-20 10:49:23 -04:00
committed by GitHub
parent 8bd2118d77
commit 639debe91b
62 changed files with 178 additions and 58 deletions

View File

@@ -0,0 +1,13 @@
const { pathsToModuleNameMapper } = require("ts-jest");
const { compilerOptions } = require("../../../../shared/tsconfig.libs");
/** @type {import('jest').Config} */
module.exports = {
testMatch: ["**/+(*.)+(spec).+(ts)"],
preset: "ts-jest",
testEnvironment: "../../../../shared/test.environment.ts",
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
prefix: "<rootDir>/../../../",
}),
};

View File

@@ -0,0 +1,24 @@
{
"name": "@bitwarden/generator-history",
"version": "0.0.0",
"description": "Bitwarden credential generator history service",
"keywords": [
"bitwarden"
],
"author": "Bitwarden Inc.",
"homepage": "https://bitwarden.com",
"repository": {
"type": "git",
"url": "https://github.com/bitwarden/clients"
},
"license": "GPL-3.0",
"scripts": {
"clean": "rimraf dist",
"build": "npm run clean && tsc",
"build:watch": "npm run clean && tsc -watch"
},
"dependencies": {
"@bitwarden/common": "file:../../../common",
"@bitwarden/generator-core": "file:../core"
}
}

View File

@@ -0,0 +1,58 @@
import { GeneratorCategory, GeneratedCredential } from ".";
describe("GeneratedCredential", () => {
describe("constructor", () => {
it("assigns credential", () => {
const result = new GeneratedCredential("example", "passphrase", new Date(100));
expect(result.credential).toEqual("example");
});
it("assigns category", () => {
const result = new GeneratedCredential("example", "passphrase", new Date(100));
expect(result.category).toEqual("passphrase");
});
it("passes through date parameters", () => {
const result = new GeneratedCredential("example", "password", new Date(100));
expect(result.generationDate).toEqual(new Date(100));
});
it("converts numeric dates to Dates", () => {
const result = new GeneratedCredential("example", "password", 100);
expect(result.generationDate).toEqual(new Date(100));
});
});
it("toJSON converts from a credential into a JSON object", () => {
const credential = new GeneratedCredential("example", "password", new Date(100));
const result = credential.toJSON();
expect(result).toEqual({
credential: "example",
category: "password" as GeneratorCategory,
generationDate: 100,
});
});
it("fromJSON converts Json objects into credentials", () => {
const jsonValue = {
credential: "example",
category: "password" as GeneratorCategory,
generationDate: 100,
};
const result = GeneratedCredential.fromJSON(jsonValue);
expect(result).toBeInstanceOf(GeneratedCredential);
expect(result).toEqual({
credential: "example",
category: "password",
generationDate: new Date(100),
});
});
});

View File

@@ -0,0 +1,47 @@
import { Jsonify } from "type-fest";
import { GeneratorCategory } from "./options";
/** A credential generation result */
export class GeneratedCredential {
/**
* Instantiates a generated credential
* @param credential The value of the generated credential (e.g. a password)
* @param category The kind of credential
* @param generationDate The date that the credential was generated.
* Numeric values should are interpreted using {@link Date.valueOf}
* semantics.
*/
constructor(
readonly credential: string,
readonly category: GeneratorCategory,
generationDate: Date | number,
) {
if (typeof generationDate === "number") {
this.generationDate = new Date(generationDate);
} else {
this.generationDate = generationDate;
}
}
/** The date that the credential was generated */
generationDate: Date;
/** Constructs a credential from its `toJSON` representation */
static fromJSON(jsonValue: Jsonify<GeneratedCredential>) {
return new GeneratedCredential(
jsonValue.credential,
jsonValue.category,
jsonValue.generationDate,
);
}
/** Serializes a credential to a JSON-compatible object */
toJSON() {
return {
credential: this.credential,
category: this.category,
generationDate: this.generationDate.valueOf(),
};
}
}

View File

@@ -0,0 +1,9 @@
export class GeneratedPasswordHistory {
password: string;
date: number;
constructor(password: string, date: number) {
this.password = password;
this.date = date;
}
}

View File

@@ -0,0 +1,55 @@
import { Observable } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { GeneratedCredential } from "./generated-credential";
import { GeneratorCategory } from "./options";
/** 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>;
/** Deletes a user's credential history.
* @param userId identifies the user taking the credential.
* @returns A promise that completes when the history is cleared.
*/
clear: (userId: UserId) => Promise<GeneratedCredential[]>;
/** Lists all credentials for a user.
* @param userId identifies the user listing the credential.
* @remarks This field is eventually consistent with `track` and `take` operations.
* It is not guaranteed to immediately reflect those changes.
*/
credentials$: (userId: UserId) => Observable<GeneratedCredential[]>;
}

View File

@@ -0,0 +1,5 @@
export { GeneratedCredential } from "./generated-credential";
export { GeneratedPasswordHistory } from "./generated-password-history";
export { GeneratorHistoryService } from "./generator-history.abstraction";
export { LocalGeneratorHistoryService } from "./local-generator-history.service";
export { GeneratorCategory } from "./options";

View File

@@ -0,0 +1,65 @@
import { mock } from "jest-mock-extended";
import { GeneratedCredential } from "./generated-credential";
import { GeneratedPasswordHistory } from "./generated-password-history";
import { GENERATOR_HISTORY_BUFFER } from "./key-definitions";
import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor";
describe("Key definitions", () => {
describe("GENERATOR_HISTORY_BUFFER", () => {
describe("options.deserializer", () => {
it("should deserialize generated password history", () => {
const value: any = [{ password: "foo", date: 1 }];
const [result] = GENERATOR_HISTORY_BUFFER.options.deserializer(value);
expect(result).toEqual(value[0]);
expect(result).toBeInstanceOf(GeneratedPasswordHistory);
});
it.each([[undefined], [null]])("should ignore nullish (= %p) history", (value: any) => {
const result = GENERATOR_HISTORY_BUFFER.options.deserializer(value);
expect(result).toEqual(undefined);
});
});
it("should map generated password history to generated credentials", async () => {
const value: any = [new GeneratedPasswordHistory("foo", 1)];
const decryptor = mock<LegacyPasswordHistoryDecryptor>({
decrypt(value) {
return Promise.resolve(value);
},
});
const [result] = await GENERATOR_HISTORY_BUFFER.map(value, decryptor);
expect(result).toEqual({
credential: "foo",
category: "password",
generationDate: new Date(1),
});
expect(result).toBeInstanceOf(GeneratedCredential);
});
describe("isValid", () => {
it("should accept histories with at least one entry", async () => {
const value: any = [new GeneratedPasswordHistory("foo", 1)];
const decryptor = {} as any;
const result = await GENERATOR_HISTORY_BUFFER.isValid(value, decryptor);
expect(result).toEqual(true);
});
it("should reject histories with no entries", async () => {
const value: any = [];
const decryptor = {} as any;
const result = await GENERATOR_HISTORY_BUFFER.isValid(value, decryptor);
expect(result).toEqual(false);
});
});
});
});

View File

@@ -0,0 +1,42 @@
import { Jsonify } from "type-fest";
import { GENERATOR_DISK } from "@bitwarden/common/platform/state";
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
import { SecretClassifier } from "@bitwarden/common/tools/state/secret-classifier";
import { SecretKeyDefinition } from "@bitwarden/common/tools/state/secret-key-definition";
import { GeneratedCredential } from "./generated-credential";
import { GeneratedPasswordHistory } from "./generated-password-history";
import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor";
/** encrypted password generation history */
export const GENERATOR_HISTORY = SecretKeyDefinition.array(
GENERATOR_DISK,
"localGeneratorHistory",
SecretClassifier.allSecret<GeneratedCredential>(),
{
deserializer: GeneratedCredential.fromJSON,
clearOn: ["logout"],
},
);
/** encrypted password generation history subject to migration */
export const GENERATOR_HISTORY_BUFFER = new BufferedKeyDefinition<
GeneratedPasswordHistory[],
GeneratedCredential[],
LegacyPasswordHistoryDecryptor
>(GENERATOR_DISK, "localGeneratorHistoryBuffer", {
deserializer(history) {
const items = history as Jsonify<GeneratedPasswordHistory>[];
return items?.map((h) => new GeneratedPasswordHistory(h.password, h.date));
},
async isValid(history) {
return history.length ? true : false;
},
async map(history, decryptor) {
const credentials = await decryptor.decrypt(history);
const mapped = credentials.map((c) => new GeneratedCredential(c.password, "password", c.date));
return mapped;
},
clearOn: ["logout"],
});

View File

@@ -0,0 +1,30 @@
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { UserId } from "@bitwarden/common/types/guid";
import { GeneratedPasswordHistory } from "./generated-password-history";
/** Strategy that decrypts a password history */
export class LegacyPasswordHistoryDecryptor {
constructor(
private userId: UserId,
private cryptoService: CryptoService,
private encryptService: EncryptService,
) {}
/** Decrypts a password history. */
async decrypt(history: GeneratedPasswordHistory[]): Promise<GeneratedPasswordHistory[]> {
const key = await this.cryptoService.getUserKey(this.userId);
const promises = (history ?? []).map(async (item) => {
const encrypted = new EncString(item.password);
const decrypted = await this.encryptService.decryptToUtf8(encrypted, key);
return new GeneratedPasswordHistory(decrypted, item.date);
});
const decrypted = await Promise.all(promises);
return decrypted;
}
}

View File

@@ -0,0 +1,200 @@
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
import { FakeStateProvider, awaitAsync, mockAccountServiceWith } from "../../../../../common/spec";
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));
keyService.getInMemoryUserKeyFor$.mockImplementation(() => of(true as unknown as UserKey));
});
afterEach(() => {
jest.resetAllMocks();
});
describe("credential$", () => {
it("returns an empty list when no credentials are stored", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
const result = await firstValueFrom(history.credentials$(SomeUser));
expect(result).toEqual([]);
});
});
describe("track", () => {
it("stores a password", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "example", "password");
await awaitAsync();
const [result] = await firstValueFrom(history.credentials$(SomeUser));
expect(result).toMatchObject({ credential: "example", category: "password" });
});
it("stores a passphrase", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "example", "passphrase");
await awaitAsync();
const [result] = await firstValueFrom(history.credentials$(SomeUser));
expect(result).toMatchObject({ credential: "example", category: "passphrase" });
});
it("stores a specific date when one is provided", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "example", "password", new Date(100));
await awaitAsync();
const [result] = await firstValueFrom(history.credentials$(SomeUser));
expect(result).toEqual({
credential: "example",
category: "password",
generationDate: new Date(100),
});
});
it("skips storing a credential when it's already stored (ignores category)", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "example", "password");
await history.track(SomeUser, "example", "password");
await history.track(SomeUser, "example", "passphrase");
await awaitAsync();
const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser));
expect(firstResult).toMatchObject({ credential: "example", category: "password" });
expect(secondResult).toBeUndefined();
});
it("stores multiple credentials when the credential value is different", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "secondResult", "password");
await history.track(SomeUser, "firstResult", "password");
await awaitAsync();
const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser));
expect(firstResult).toMatchObject({ credential: "firstResult", category: "password" });
expect(secondResult).toMatchObject({ credential: "secondResult", category: "password" });
});
it("removes history items exceeding maxTotal configuration", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider, {
maxTotal: 1,
});
await history.track(SomeUser, "removed result", "password");
await history.track(SomeUser, "example", "password");
await awaitAsync();
const [firstResult, secondResult] = await firstValueFrom(history.credentials$(SomeUser));
expect(firstResult).toMatchObject({ credential: "example", category: "password" });
expect(secondResult).toBeUndefined();
});
it("stores history items in per-user collections", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider, {
maxTotal: 1,
});
await history.track(SomeUser, "some user example", "password");
await history.track(AnotherUser, "another user example", "password");
await awaitAsync();
const [someFirstResult, someSecondResult] = await firstValueFrom(
history.credentials$(SomeUser),
);
const [anotherFirstResult, anotherSecondResult] = await firstValueFrom(
history.credentials$(AnotherUser),
);
expect(someFirstResult).toMatchObject({
credential: "some user example",
category: "password",
});
expect(someSecondResult).toBeUndefined();
expect(anotherFirstResult).toMatchObject({
credential: "another user example",
category: "password",
});
expect(anotherSecondResult).toBeUndefined();
});
});
describe("take", () => {
it("returns null when there are no credentials stored", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
const result = await history.take(SomeUser, "example");
expect(result).toBeNull();
});
it("returns null when the credential wasn't found", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "example", "password");
const result = await history.take(SomeUser, "not found");
expect(result).toBeNull();
});
it("returns a matching credential", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "example", "password");
const result = await history.take(SomeUser, "example");
expect(result).toMatchObject({
credential: "example",
category: "password",
});
});
it("removes a matching credential", async () => {
const stateProvider = new FakeStateProvider(mockAccountServiceWith(SomeUser));
const history = new LocalGeneratorHistoryService(encryptService, keyService, stateProvider);
await history.track(SomeUser, "example", "password");
await history.take(SomeUser, "example");
await awaitAsync();
const results = await firstValueFrom(history.credentials$(SomeUser));
expect(results).toEqual([]);
});
});
});

View File

@@ -0,0 +1,145 @@
import { map } from "rxjs";
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
import { BufferedState } from "@bitwarden/common/tools/state/buffered-state";
import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer";
import { SecretState } from "@bitwarden/common/tools/state/secret-state";
import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor";
import { UserId } from "@bitwarden/common/types/guid";
import { GeneratedCredential } from "./generated-credential";
import { GeneratorHistoryService } from "./generator-history.abstraction";
import { GENERATOR_HISTORY, GENERATOR_HISTORY_BUFFER } from "./key-definitions";
import { LegacyPasswordHistoryDecryptor } from "./legacy-password-history-decryptor";
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) ?? false),
},
);
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.take} */
clear = async (userId: UserId) => {
const state = this.getCredentialState(userId);
const result = (await state.update(() => null)) ?? [];
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): SingleUserState<GeneratedCredential[]> {
// construct the encryptor
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE);
const encryptor = new UserKeyEncryptor(this.encryptService, this.keyService, packer);
// construct the durable state
const state = SecretState.from<
GeneratedCredential[],
number,
GeneratedCredential,
Record<keyof GeneratedCredential, never>,
GeneratedCredential
>(userId, GENERATOR_HISTORY, this.stateProvider, encryptor);
// decryptor is just an algorithm, but it can't run until the key is available;
// providing it via an observable makes running it early impossible
const decryptor = new LegacyPasswordHistoryDecryptor(
userId,
this.keyService,
this.encryptService,
);
const decryptor$ = this.keyService
.getInMemoryUserKeyFor$(userId)
.pipe(map((key) => key && decryptor));
// move data from the old password history once decryptor is available
const buffer = new BufferedState(
this.stateProvider,
GENERATOR_HISTORY_BUFFER,
state,
decryptor$,
);
return buffer;
}
}

View File

@@ -0,0 +1,10 @@
/** Kinds of credentials that can be stored by the history service */
export type GeneratorCategory = "password" | "passphrase";
/** Configuration options for the history service */
export type HistoryServiceOptions = {
/** Total number of records retained across all types.
* @remarks Setting this to 0 or less disables history completely.
* */
maxTotal: number;
};

View File

@@ -0,0 +1,5 @@
{
"extends": "../../../../shared/tsconfig.libs",
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "./tsconfig.json"
}