1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-30 15:13:32 +00:00

[PM-6819] Credential generator MV3 integration (#8998)

* replace `PasswordGeneratorService` with `legacyPasswordGenerationServiceFactory`
* replace `UsernameGeneratorService` with `legacyUsernameGenerationServiceFactory`
* migrate generator options and history
* apply policy immediately once available
* suppress duplicate policy emissions
* run password generation response code in `ngZone`
This commit is contained in:
✨ Audrey ✨
2024-05-20 13:08:49 -04:00
committed by GitHub
parent 97c7ef3f21
commit a16dc84a0a
59 changed files with 1995 additions and 399 deletions

View File

@@ -60,13 +60,16 @@ import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key
import { KnownAccountsMigrator } from "./migrations/60-known-accounts";
import { PinStateMigrator } from "./migrations/61-move-pin-state-to-providers";
import { VaultTimeoutSettingsServiceStateProviderMigrator } from "./migrations/62-migrate-vault-timeout-settings-svc-to-state-provider";
import { PasswordOptionsMigrator } from "./migrations/63-migrate-password-settings";
import { GeneratorHistoryMigrator } from "./migrations/64-migrate-generator-history";
import { ForwarderOptionsMigrator } from "./migrations/65-migrate-forwarder-settings";
import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account";
import { MoveStateVersionMigrator } from "./migrations/8-move-state-version";
import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-settings-to-global";
import { MinVersionMigrator } from "./migrations/min-version";
export const MIN_VERSION = 3;
export const CURRENT_VERSION = 62;
export const CURRENT_VERSION = 65;
export type MinVersion = typeof MIN_VERSION;
export function createMigrationBuilder() {
@@ -130,7 +133,10 @@ export function createMigrationBuilder() {
.with(KdfConfigMigrator, 58, 59)
.with(KnownAccountsMigrator, 59, 60)
.with(PinStateMigrator, 60, 61)
.with(VaultTimeoutSettingsServiceStateProviderMigrator, 61, CURRENT_VERSION);
.with(VaultTimeoutSettingsServiceStateProviderMigrator, 61, 62)
.with(PasswordOptionsMigrator, 62, 63)
.with(GeneratorHistoryMigrator, 63, 64)
.with(ForwarderOptionsMigrator, 64, CURRENT_VERSION);
}
export async function currentVersion(

View File

@@ -0,0 +1,123 @@
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
ExpectedOptions,
PasswordOptionsMigrator,
NAVIGATION,
PASSWORD,
PASSPHRASE,
} from "./63-migrate-password-settings";
function migrationHelper(passwordGenerationOptions: ExpectedOptions) {
const helper = mockMigrationHelper(
{
global_account_accounts: {
SomeAccount: {
email: "SomeAccount",
name: "SomeAccount",
emailVerified: true,
},
},
SomeAccount: {
settings: {
passwordGenerationOptions,
this: {
looks: "important",
},
},
cant: {
touch: "this",
},
},
},
62,
);
return helper;
}
function expectOtherSettingsRemain(helper: MigrationHelper) {
expect(helper.set).toHaveBeenCalledWith("SomeAccount", {
settings: {
this: {
looks: "important",
},
},
cant: {
touch: "this",
},
});
}
describe("PasswordOptionsMigrator", () => {
describe("migrate", () => {
it("migrates generator type", async () => {
const helper = migrationHelper({
type: "password",
});
helper.getFromUser.mockResolvedValue({ some: { other: "data" } });
const migrator = new PasswordOptionsMigrator(62, 63);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", NAVIGATION, {
type: "password",
some: { other: "data" },
});
expectOtherSettingsRemain(helper);
});
it("migrates password settings", async () => {
const helper = migrationHelper({
length: 20,
ambiguous: true,
uppercase: false,
minUppercase: 4,
lowercase: true,
minLowercase: 3,
number: false,
minNumber: 2,
special: true,
minSpecial: 1,
});
const migrator = new PasswordOptionsMigrator(62, 63);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", PASSWORD, {
length: 20,
ambiguous: true,
uppercase: false,
minUppercase: 4,
lowercase: true,
minLowercase: 3,
number: false,
minNumber: 2,
special: true,
minSpecial: 1,
});
expectOtherSettingsRemain(helper);
});
it("migrates passphrase settings", async () => {
const helper = migrationHelper({
numWords: 5,
wordSeparator: "4",
capitalize: true,
includeNumber: false,
});
const migrator = new PasswordOptionsMigrator(62, 63);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", PASSPHRASE, {
numWords: 5,
wordSeparator: "4",
capitalize: true,
includeNumber: false,
});
expectOtherSettingsRemain(helper);
});
});
});

View File

@@ -0,0 +1,150 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
/** settings targeted by migrator */
export type AccountType = {
settings?: {
passwordGenerationOptions?: ExpectedOptions;
};
};
export type GeneratorType = "password" | "passphrase" | "username";
/** username generation options prior to refactoring */
export type ExpectedOptions = {
type?: GeneratorType;
length?: number;
minLength?: number;
ambiguous?: boolean;
uppercase?: boolean;
minUppercase?: number;
lowercase?: boolean;
minLowercase?: number;
number?: boolean;
minNumber?: number;
special?: boolean;
minSpecial?: number;
numWords?: number;
wordSeparator?: string;
capitalize?: boolean;
includeNumber?: boolean;
};
/** username generation options after refactoring */
type ConvertedOptions = {
generator: GeneratorNavigation;
password: PasswordGenerationOptions;
passphrase: PassphraseGenerationOptions;
};
export const NAVIGATION: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "generatorSettings",
};
export const PASSWORD: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "passwordGeneratorSettings",
};
export const PASSPHRASE: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "passphraseGeneratorSettings",
};
export type GeneratorNavigation = {
type?: string;
};
export type PassphraseGenerationOptions = {
numWords?: number;
wordSeparator?: string;
capitalize?: boolean;
includeNumber?: boolean;
};
export type PasswordGenerationOptions = {
length?: number;
minLength?: number;
ambiguous?: boolean;
uppercase?: boolean;
minUppercase?: number;
lowercase?: boolean;
minLowercase?: number;
number?: boolean;
minNumber?: number;
special?: boolean;
minSpecial?: number;
};
export class PasswordOptionsMigrator extends Migrator<62, 63> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<AccountType>();
async function migrateAccount(userId: string, account: AccountType) {
const legacyOptions = account?.settings?.passwordGenerationOptions;
if (legacyOptions) {
const converted = convertSettings(legacyOptions);
await storeSettings(helper, userId, converted);
await deleteSettings(helper, userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
// not supported
}
}
function convertSettings(options: ExpectedOptions): ConvertedOptions {
const password = {
length: options.length,
ambiguous: options.ambiguous,
uppercase: options.uppercase,
minUppercase: options.minUppercase,
lowercase: options.lowercase,
minLowercase: options.minLowercase,
number: options.number,
minNumber: options.minNumber,
special: options.special,
minSpecial: options.minSpecial,
};
const generator = {
type: options.type,
};
const passphrase = {
numWords: options.numWords,
wordSeparator: options.wordSeparator,
capitalize: options.capitalize,
includeNumber: options.includeNumber,
};
return { generator, password, passphrase };
}
async function storeSettings(helper: MigrationHelper, userId: string, converted: ConvertedOptions) {
const existing = (await helper.getFromUser(userId, NAVIGATION)) ?? {};
const updated = Object.assign(existing, converted.generator);
await Promise.all([
helper.setToUser(userId, NAVIGATION, updated),
helper.setToUser(userId, PASSPHRASE, converted.passphrase),
helper.setToUser(userId, PASSWORD, converted.password),
]);
}
async function deleteSettings(helper: MigrationHelper, userId: string, account: AccountType) {
delete account?.settings?.passwordGenerationOptions;
await helper.set(userId, account);
}

View File

@@ -0,0 +1,68 @@
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
EncryptedHistory,
GeneratorHistoryMigrator,
HISTORY,
} from "./64-migrate-generator-history";
function migrationHelper(encrypted: EncryptedHistory) {
const helper = mockMigrationHelper(
{
global_account_accounts: {
SomeAccount: {
email: "SomeAccount",
name: "SomeAccount",
emailVerified: true,
},
},
SomeAccount: {
data: {
passwordGenerationHistory: {
encrypted,
},
this: {
looks: "important",
},
},
cant: {
touch: "this",
},
},
},
63,
);
return helper;
}
function expectOtherSettingsRemain(helper: MigrationHelper) {
expect(helper.set).toHaveBeenCalledWith("SomeAccount", {
data: {
this: {
looks: "important",
},
},
cant: {
touch: "this",
},
});
}
describe("PasswordOptionsMigrator", () => {
describe("migrate", () => {
it("migrates generator type", async () => {
const helper = migrationHelper([{ this: "should be copied" }, { this: "too" }]);
const migrator = new GeneratorHistoryMigrator(63, 64);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", HISTORY, [
{ this: "should be copied" },
{ this: "too" },
]);
expectOtherSettingsRemain(helper);
});
});
});

View File

@@ -0,0 +1,42 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
/** settings targeted by migrator */
export type AccountType = {
data?: {
passwordGenerationHistory?: {
encrypted: EncryptedHistory;
};
};
};
/** the actual data stored in the history is opaque to the migrator */
export type EncryptedHistory = Array<unknown>;
export const HISTORY: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "localGeneratorHistoryBuffer",
};
export class GeneratorHistoryMigrator extends Migrator<63, 64> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<AccountType>();
async function migrateAccount(userId: string, account: AccountType) {
const data = account?.data?.passwordGenerationHistory;
if (data && data.encrypted) {
await helper.setToUser(userId, HISTORY, data.encrypted);
delete account.data.passwordGenerationHistory;
await helper.set(userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
// not supported
}
}

View File

@@ -0,0 +1,218 @@
import { MigrationHelper } from "../migration-helper";
import { mockMigrationHelper } from "../migration-helper.spec";
import {
ADDY_IO,
CATCHALL,
DUCK_DUCK_GO,
EFF_USERNAME,
ExpectedOptions,
FASTMAIL,
FIREFOX_RELAY,
FORWARD_EMAIL,
ForwarderOptionsMigrator,
NAVIGATION,
SIMPLE_LOGIN,
SUBADDRESS,
} from "./65-migrate-forwarder-settings";
function migrationHelper(usernameGenerationOptions: ExpectedOptions) {
const helper = mockMigrationHelper(
{
global_account_accounts: {
SomeAccount: {
email: "SomeAccount",
name: "SomeAccount",
emailVerified: true,
},
},
SomeAccount: {
settings: {
usernameGenerationOptions,
this: {
looks: "important",
},
},
cant: {
touch: "this",
},
},
},
64,
);
return helper;
}
function expectOtherSettingsRemain(helper: MigrationHelper) {
expect(helper.set).toHaveBeenCalledWith("SomeAccount", {
settings: {
this: {
looks: "important",
},
},
cant: {
touch: "this",
},
});
}
describe("ForwarderOptionsMigrator", () => {
describe("migrate", () => {
it("migrates generator settings", async () => {
const helper = migrationHelper({
type: "catchall",
forwardedService: "simplelogin",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", NAVIGATION, {
username: "catchall",
forwarder: "simplelogin",
});
expectOtherSettingsRemain(helper);
});
it("migrates catchall settings", async () => {
const helper = migrationHelper({
catchallType: "random",
catchallDomain: "example.com",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", CATCHALL, {
catchallType: "random",
catchallDomain: "example.com",
});
expectOtherSettingsRemain(helper);
});
it("migrates EFF username settings", async () => {
const helper = migrationHelper({
wordCapitalize: true,
wordIncludeNumber: false,
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", EFF_USERNAME, {
wordCapitalize: true,
wordIncludeNumber: false,
});
expectOtherSettingsRemain(helper);
});
it("migrates subaddress settings", async () => {
const helper = migrationHelper({
subaddressType: "random",
subaddressEmail: "j.d@example.com",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", SUBADDRESS, {
subaddressType: "random",
subaddressEmail: "j.d@example.com",
});
expectOtherSettingsRemain(helper);
});
it("migrates addyIo settings", async () => {
const helper = migrationHelper({
forwardedAnonAddyBaseUrl: "some_addyio_base",
forwardedAnonAddyApiToken: "some_addyio_token",
forwardedAnonAddyDomain: "some_addyio_domain",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", ADDY_IO, {
baseUrl: "some_addyio_base",
token: "some_addyio_token",
domain: "some_addyio_domain",
});
expectOtherSettingsRemain(helper);
});
it("migrates DuckDuckGo settings", async () => {
const helper = migrationHelper({
forwardedDuckDuckGoToken: "some_duckduckgo_token",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", DUCK_DUCK_GO, {
token: "some_duckduckgo_token",
});
expectOtherSettingsRemain(helper);
});
it("migrates Firefox Relay settings", async () => {
const helper = migrationHelper({
forwardedFirefoxApiToken: "some_firefox_token",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", FIREFOX_RELAY, {
token: "some_firefox_token",
});
expectOtherSettingsRemain(helper);
});
it("migrates Fastmail settings", async () => {
const helper = migrationHelper({
forwardedFastmailApiToken: "some_fastmail_token",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", FASTMAIL, {
token: "some_fastmail_token",
});
expectOtherSettingsRemain(helper);
});
it("migrates ForwardEmail settings", async () => {
const helper = migrationHelper({
forwardedForwardEmailApiToken: "some_forwardemail_token",
forwardedForwardEmailDomain: "some_forwardemail_domain",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", FORWARD_EMAIL, {
token: "some_forwardemail_token",
domain: "some_forwardemail_domain",
});
expectOtherSettingsRemain(helper);
});
it("migrates SimpleLogin settings", async () => {
const helper = migrationHelper({
forwardedSimpleLoginApiKey: "some_simplelogin_token",
forwardedSimpleLoginBaseUrl: "some_simplelogin_baseurl",
});
const migrator = new ForwarderOptionsMigrator(64, 65);
await migrator.migrate(helper);
expect(helper.setToUser).toHaveBeenCalledWith("SomeAccount", SIMPLE_LOGIN, {
token: "some_simplelogin_token",
baseUrl: "some_simplelogin_baseurl",
});
expectOtherSettingsRemain(helper);
});
});
});

View File

@@ -0,0 +1,245 @@
import { KeyDefinitionLike, MigrationHelper } from "../migration-helper";
import { Migrator } from "../migrator";
/** settings targeted by migrator */
export type AccountType = {
settings?: {
usernameGenerationOptions?: ExpectedOptions;
};
};
/** username generation options prior to refactoring */
export type ExpectedOptions = {
type?: "word" | "subaddress" | "catchall" | "forwarded";
wordCapitalize?: boolean;
wordIncludeNumber?: boolean;
subaddressType?: "random" | "website-name";
subaddressEmail?: string;
catchallType?: "random" | "website-name";
catchallDomain?: string;
forwardedService?: string;
forwardedAnonAddyApiToken?: string;
forwardedAnonAddyDomain?: string;
forwardedAnonAddyBaseUrl?: string;
forwardedDuckDuckGoToken?: string;
forwardedFirefoxApiToken?: string;
forwardedFastmailApiToken?: string;
forwardedForwardEmailApiToken?: string;
forwardedForwardEmailDomain?: string;
forwardedSimpleLoginApiKey?: string;
forwardedSimpleLoginBaseUrl?: string;
};
/** username generation options after refactoring */
type ConvertedOptions = {
generator: GeneratorNavigation;
algorithms: {
catchall: CatchallGenerationOptions;
effUsername: EffUsernameGenerationOptions;
subaddress: SubaddressGenerationOptions;
};
forwarders: {
addyIo: SelfHostedApiOptions & EmailDomainOptions;
duckDuckGo: ApiOptions;
fastmail: ApiOptions;
firefoxRelay: ApiOptions;
forwardEmail: ApiOptions & EmailDomainOptions;
simpleLogin: SelfHostedApiOptions;
};
};
export const NAVIGATION: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "generatorSettings",
};
export const CATCHALL: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "catchallGeneratorSettings",
};
export const EFF_USERNAME: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "effUsernameGeneratorSettings",
};
export const SUBADDRESS: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "subaddressGeneratorSettings",
};
export const ADDY_IO: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "addyIoBuffer",
};
export const DUCK_DUCK_GO: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "duckDuckGoBuffer",
};
export const FASTMAIL: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "fastmailBuffer",
};
export const FIREFOX_RELAY: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "firefoxRelayBuffer",
};
export const FORWARD_EMAIL: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "forwardEmailBuffer",
};
export const SIMPLE_LOGIN: KeyDefinitionLike = {
stateDefinition: {
name: "generator",
},
key: "simpleLoginBuffer",
};
export type GeneratorNavigation = {
type?: string;
username?: string;
forwarder?: string;
};
type UsernameGenerationMode = "random" | "website-name";
type CatchallGenerationOptions = {
catchallType?: UsernameGenerationMode;
catchallDomain?: string;
};
type EffUsernameGenerationOptions = {
wordCapitalize?: boolean;
wordIncludeNumber?: boolean;
};
type SubaddressGenerationOptions = {
subaddressType?: UsernameGenerationMode;
subaddressEmail?: string;
};
type ApiOptions = {
token?: string;
};
type SelfHostedApiOptions = ApiOptions & {
baseUrl: string;
};
type EmailDomainOptions = {
domain: string;
};
export class ForwarderOptionsMigrator extends Migrator<64, 65> {
async migrate(helper: MigrationHelper): Promise<void> {
const accounts = await helper.getAccounts<AccountType>();
async function migrateAccount(userId: string, account: AccountType) {
const legacyOptions = account?.settings?.usernameGenerationOptions;
if (legacyOptions) {
const converted = convertSettings(legacyOptions);
await storeSettings(helper, userId, converted);
await deleteSettings(helper, userId, account);
}
}
await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]);
}
async rollback(helper: MigrationHelper): Promise<void> {
// not supported
}
}
function convertSettings(options: ExpectedOptions): ConvertedOptions {
const forwarders = {
addyIo: {
baseUrl: options.forwardedAnonAddyBaseUrl,
token: options.forwardedAnonAddyApiToken,
domain: options.forwardedAnonAddyDomain,
},
duckDuckGo: {
token: options.forwardedDuckDuckGoToken,
},
fastmail: {
token: options.forwardedFastmailApiToken,
},
firefoxRelay: {
token: options.forwardedFirefoxApiToken,
},
forwardEmail: {
token: options.forwardedForwardEmailApiToken,
domain: options.forwardedForwardEmailDomain,
},
simpleLogin: {
token: options.forwardedSimpleLoginApiKey,
baseUrl: options.forwardedSimpleLoginBaseUrl,
},
};
const generator = {
username: options.type,
forwarder: options.forwardedService,
};
const algorithms = {
effUsername: {
wordCapitalize: options.wordCapitalize,
wordIncludeNumber: options.wordIncludeNumber,
},
subaddress: {
subaddressType: options.subaddressType,
subaddressEmail: options.subaddressEmail,
},
catchall: {
catchallType: options.catchallType,
catchallDomain: options.catchallDomain,
},
};
return { generator, algorithms, forwarders };
}
async function storeSettings(helper: MigrationHelper, userId: string, converted: ConvertedOptions) {
await Promise.all([
helper.setToUser(userId, NAVIGATION, converted.generator),
helper.setToUser(userId, CATCHALL, converted.algorithms.catchall),
helper.setToUser(userId, EFF_USERNAME, converted.algorithms.effUsername),
helper.setToUser(userId, SUBADDRESS, converted.algorithms.subaddress),
helper.setToUser(userId, ADDY_IO, converted.forwarders.addyIo),
helper.setToUser(userId, DUCK_DUCK_GO, converted.forwarders.duckDuckGo),
helper.setToUser(userId, FASTMAIL, converted.forwarders.fastmail),
helper.setToUser(userId, FIREFOX_RELAY, converted.forwarders.firefoxRelay),
helper.setToUser(userId, FORWARD_EMAIL, converted.forwarders.forwardEmail),
helper.setToUser(userId, SIMPLE_LOGIN, converted.forwarders.simpleLogin),
]);
}
async function deleteSettings(helper: MigrationHelper, userId: string, account: AccountType) {
delete account?.settings?.usernameGenerationOptions;
await helper.set(userId, account);
}