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:
13
libs/tools/generator/extensions/history/jest.config.js
Normal file
13
libs/tools/generator/extensions/history/jest.config.js
Normal 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>/../../../",
|
||||
}),
|
||||
};
|
||||
24
libs/tools/generator/extensions/history/package.json
Normal file
24
libs/tools/generator/extensions/history/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export class GeneratedPasswordHistory {
|
||||
password: string;
|
||||
date: number;
|
||||
|
||||
constructor(password: string, date: number) {
|
||||
this.password = password;
|
||||
this.date = date;
|
||||
}
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
5
libs/tools/generator/extensions/history/src/index.ts
Normal file
5
libs/tools/generator/extensions/history/src/index.ts
Normal 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";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
10
libs/tools/generator/extensions/history/src/options.ts
Normal file
10
libs/tools/generator/extensions/history/src/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;
|
||||
};
|
||||
5
libs/tools/generator/extensions/history/tsconfig.json
Normal file
5
libs/tools/generator/extensions/history/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../../../../shared/tsconfig.libs",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./tsconfig.json"
|
||||
}
|
||||
Reference in New Issue
Block a user