1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-24677] Slim StateService down so it can be moved to state lib (#16021)

* Slim StateService down so it can be moved to state lib

* Fix accidental import changes

* Add `switchAccount` assertion

* Needs to use mock
This commit is contained in:
Justin Baur
2025-08-18 12:37:25 -04:00
committed by GitHub
parent ea305a0f71
commit 939fd402c3
49 changed files with 286 additions and 1274 deletions

View File

@@ -13,7 +13,6 @@ import {
ObservableStorageService,
} from "@bitwarden/common/platform/abstractions/storage.service";
import { Theme } from "@bitwarden/common/platform/enums";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { Message } from "@bitwarden/common/platform/messaging";
import { HttpOperations } from "@bitwarden/common/services/api.service";
import { SafeInjectionToken } from "@bitwarden/ui-common";
@@ -33,7 +32,6 @@ export const OBSERVABLE_DISK_LOCAL_STORAGE = new SafeInjectionToken<
>("OBSERVABLE_DISK_LOCAL_STORAGE");
export const MEMORY_STORAGE = new SafeInjectionToken<AbstractStorageService>("MEMORY_STORAGE");
export const SECURE_STORAGE = new SafeInjectionToken<AbstractStorageService>("SECURE_STORAGE");
export const STATE_FACTORY = new SafeInjectionToken<StateFactory>("STATE_FACTORY");
export const LOGOUT_CALLBACK = new SafeInjectionToken<
(logoutReason: LogoutReason, userId?: string) => Promise<void>
>("LOGOUT_CALLBACK");

View File

@@ -196,13 +196,10 @@ import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.serv
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
import { devFlagEnabled } from "@bitwarden/common/platform/misc/flags";
import { Account } from "@bitwarden/common/platform/models/domain/account";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
import { NotificationsService } from "@bitwarden/common/platform/notifications";
// eslint-disable-next-line no-restricted-imports -- Needed for service creation
import {
@@ -228,13 +225,13 @@ import { FileUploadService } from "@bitwarden/common/platform/services/file-uplo
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { DefaultSdkService } from "@bitwarden/common/platform/services/sdk/default-sdk.service";
import { StateService } from "@bitwarden/common/platform/services/state.service";
import { StorageServiceProvider } from "@bitwarden/common/platform/services/storage-service.provider";
import { UserAutoUnlockKeyService } from "@bitwarden/common/platform/services/user-auto-unlock-key.service";
import { ValidationService } from "@bitwarden/common/platform/services/validation.service";
import {
ActiveUserAccessor,
ActiveUserStateProvider,
DefaultStateService,
DerivedStateProvider,
GlobalStateProvider,
SingleUserStateProvider,
@@ -371,12 +368,10 @@ import {
LOCKED_CALLBACK,
LOG_MAC_FAILURES,
LOGOUT_CALLBACK,
MEMORY_STORAGE,
OBSERVABLE_DISK_STORAGE,
OBSERVABLE_MEMORY_STORAGE,
REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
SECURE_STORAGE,
STATE_FACTORY,
SUPPORTS_SECURE_STORAGE,
SYSTEM_LANGUAGE,
SYSTEM_THEME_OBSERVABLE,
@@ -414,10 +409,6 @@ const safeProviders: SafeProvider[] = [
useFactory: (window: Window) => window.navigator.language,
deps: [WINDOW],
}),
safeProvider({
provide: STATE_FACTORY,
useValue: new StateFactory(GlobalState, Account),
}),
// TODO: PM-21212 - Deprecate LogoutCallback in favor of LogoutService
safeProvider({
provide: LOGOUT_CALLBACK,
@@ -530,7 +521,6 @@ const safeProviders: SafeProvider[] = [
apiService: ApiServiceAbstraction,
i18nService: I18nServiceAbstraction,
searchService: SearchServiceAbstraction,
stateService: StateServiceAbstraction,
autofillSettingsService: AutofillSettingsServiceAbstraction,
encryptService: EncryptService,
fileUploadService: CipherFileUploadServiceAbstraction,
@@ -547,7 +537,6 @@ const safeProviders: SafeProvider[] = [
apiService,
i18nService,
searchService,
stateService,
autofillSettingsService,
encryptService,
fileUploadService,
@@ -564,7 +553,6 @@ const safeProviders: SafeProvider[] = [
ApiServiceAbstraction,
I18nServiceAbstraction,
SearchServiceAbstraction,
StateServiceAbstraction,
AutofillSettingsServiceAbstraction,
EncryptService,
CipherFileUploadServiceAbstraction,
@@ -801,7 +789,6 @@ const safeProviders: SafeProvider[] = [
InternalSendService,
LogService,
KeyConnectorServiceAbstraction,
StateServiceAbstraction,
ProviderServiceAbstraction,
FolderApiServiceAbstraction,
InternalOrganizationServiceAbstraction,
@@ -849,6 +836,7 @@ const safeProviders: SafeProvider[] = [
MessagingServiceAbstraction,
SearchServiceAbstraction,
StateServiceAbstraction,
TokenServiceAbstraction,
AuthServiceAbstraction,
VaultTimeoutSettingsService,
StateEventRunnerService,
@@ -868,24 +856,10 @@ const safeProviders: SafeProvider[] = [
useClass: SsoLoginService,
deps: [StateProvider, LogService],
}),
safeProvider({
provide: STATE_FACTORY,
useValue: new StateFactory(GlobalState, Account),
}),
safeProvider({
provide: StateServiceAbstraction,
useClass: StateService,
deps: [
AbstractStorageService,
SECURE_STORAGE,
MEMORY_STORAGE,
LogService,
STATE_FACTORY,
AccountServiceAbstraction,
EnvironmentService,
TokenServiceAbstraction,
MigrationRunner,
],
useClass: DefaultStateService,
deps: [AbstractStorageService, SECURE_STORAGE, ActiveUserAccessor],
}),
safeProvider({
provide: IndividualVaultExportServiceAbstraction,

View File

@@ -32,7 +32,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { Account, AccountProfile } from "@bitwarden/common/platform/models/domain/account";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import {
@@ -243,18 +242,8 @@ describe("LoginStrategy", () => {
refreshToken,
);
expect(stateService.addAccount).toHaveBeenCalledWith(
new Account({
profile: {
...new AccountProfile(),
...{
userId: userId,
name: name,
email: email,
},
},
}),
);
expect(environmentService.seedUserEnvironment).toHaveBeenCalled();
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
UserDecryptionOptions.fromResponse(idTokenResponse),
);
@@ -388,7 +377,8 @@ describe("LoginStrategy", () => {
const result = await passwordLoginStrategy.logIn(credentials);
expect(stateService.addAccount).not.toHaveBeenCalled();
expect(environmentService.seedUserEnvironment).not.toHaveBeenCalled();
expect(accountService.mock.switchAccount).not.toHaveBeenCalled();
expect(messagingService.send).not.toHaveBeenCalled();
expect(tokenService.clearTwoFactorToken).toHaveBeenCalled();
@@ -422,7 +412,7 @@ describe("LoginStrategy", () => {
const result = await passwordLoginStrategy.logIn(credentials);
expect(stateService.addAccount).not.toHaveBeenCalled();
expect(environmentService.seedUserEnvironment).not.toHaveBeenCalled();
expect(messagingService.send).not.toHaveBeenCalled();
const expected = new AuthResult();

View File

@@ -32,7 +32,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { Account, AccountProfile } from "@bitwarden/common/platform/models/domain/account";
import { UserId } from "@bitwarden/common/types/guid";
import {
KeyService,
@@ -198,19 +197,6 @@ export abstract class LoginStrategy {
await this.accountService.switchAccount(userId);
await this.stateService.addAccount(
new Account({
profile: {
...new AccountProfile(),
...{
userId,
name: accountInformation.name,
email: accountInformation.email,
},
},
}),
);
await this.verifyAccountAdded(userId);
// We must set user decryption options before retrieving vault timeout settings

View File

@@ -170,7 +170,7 @@ describe("UserApiLoginStrategy", () => {
mockVaultTimeoutAction,
mockVaultTimeout,
);
expect(stateService.addAccount).toHaveBeenCalled();
expect(environmentService.seedUserEnvironment).toHaveBeenCalled();
});
it("sets the encrypted user key and private key from the identity token response", async () => {

View File

@@ -12,15 +12,16 @@ import { LogoutReason } from "@bitwarden/auth/common";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { BiometricsService } from "@bitwarden/key-management";
import { StateService } from "@bitwarden/state";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
import { AccountInfo } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { TokenService } from "../../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { LogService } from "../../../platform/abstractions/log.service";
import { MessagingService } from "../../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { StateService } from "../../../platform/abstractions/state.service";
import { Utils } from "../../../platform/misc/utils";
import { TaskSchedulerService } from "../../../platform/scheduling";
import { StateEventRunnerService } from "../../../platform/state";
@@ -45,6 +46,7 @@ describe("VaultTimeoutService", () => {
let messagingService: MockProxy<MessagingService>;
let searchService: MockProxy<SearchService>;
let stateService: MockProxy<StateService>;
let tokenService: MockProxy<TokenService>;
let authService: MockProxy<AuthService>;
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
@@ -71,6 +73,7 @@ describe("VaultTimeoutService", () => {
messagingService = mock();
searchService = mock();
stateService = mock();
tokenService = mock();
authService = mock();
vaultTimeoutSettingsService = mock();
stateEventRunnerService = mock();
@@ -99,6 +102,7 @@ describe("VaultTimeoutService", () => {
messagingService,
searchService,
stateService,
tokenService,
authService,
vaultTimeoutSettingsService,
stateEventRunnerService,
@@ -141,9 +145,8 @@ describe("VaultTimeoutService", () => {
authService.getAuthStatus.mockImplementation((userId) => {
return Promise.resolve(accounts[userId]?.authStatus);
});
stateService.getIsAuthenticated.mockImplementation((options) => {
// Just like actual state service, if no userId is given fallback to active userId
return Promise.resolve(accounts[options.userId ?? globalSetups?.userId]?.isAuthenticated);
tokenService.hasAccessToken$.mockImplementation((userId) => {
return of(accounts[userId]?.isAuthenticated ?? false);
});
vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation((userId) => {
@@ -201,7 +204,7 @@ describe("VaultTimeoutService", () => {
const expectUserToHaveLocked = (userId: string) => {
// This does NOT assert all the things that the lock process does
expect(stateService.getIsAuthenticated).toHaveBeenCalledWith({ userId: userId });
expect(tokenService.hasAccessToken$).toHaveBeenCalledWith(userId);
expect(vaultTimeoutSettingsService.availableVaultTimeoutActions$).toHaveBeenCalledWith(userId);
expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, { userId: userId });
expect(masterPasswordService.mock.clearMasterKey).toHaveBeenCalledWith(userId);

View File

@@ -14,6 +14,7 @@ import { BiometricsService } from "@bitwarden/key-management";
import { AccountService } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { TokenService } from "../../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { LogService } from "../../../platform/abstractions/log.service";
import { MessagingService } from "../../../platform/abstractions/messaging.service";
@@ -43,6 +44,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
private messagingService: MessagingService,
private searchService: SearchService,
private stateService: StateService,
private tokenService: TokenService,
private authService: AuthService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private stateEventRunnerService: StateEventRunnerService,
@@ -108,7 +110,10 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
async lock(userId?: UserId): Promise<void> {
await this.biometricService.setShouldAutopromptNow(false);
const authed = await this.stateService.getIsAuthenticated({ userId: userId });
const lockingUserId =
userId ?? (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))));
const authed = await firstValueFrom(this.tokenService.hasAccessToken$(lockingUserId));
if (!authed) {
return;
}
@@ -121,12 +126,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await this.logOut(userId);
}
const currentUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const lockingUserId = userId ?? currentUserId;
// HACK: Start listening for the transition of the locking user from something to the locked state.
// This is very much a hack to ensure that the authentication status to retrievable right after
// it does its work. Particularly the `lockedCallback` and `"locked"` message. Instead

View File

@@ -1,63 +1 @@
import { BiometricKey } from "../../auth/types/biometric-key";
import { Account } from "../models/domain/account";
import { StorageOptions } from "../models/domain/storage-options";
/**
* Options for customizing the initiation behavior.
*/
export type InitOptions = {
/**
* Whether or not to run state migrations as part of the init process. Defaults to true.
*
* If false, the init method will instead wait for migrations to complete before doing its
* other init operations. Make sure migrations have either already completed, or will complete
* before calling {@link StateService.init} with `runMigrations: false`.
*/
runMigrations?: boolean;
};
export abstract class StateService<T extends Account = Account> {
abstract addAccount(account: T): Promise<void>;
abstract clean(options?: StorageOptions): Promise<void>;
abstract init(initOptions?: InitOptions): Promise<void>;
/**
* Gets the user's auto key
*/
abstract getUserKeyAutoUnlock(options?: StorageOptions): Promise<string>;
/**
* Sets the user's auto key
*/
abstract setUserKeyAutoUnlock(value: string | null, options?: StorageOptions): Promise<void>;
/**
* Gets the user's biometric key
*/
abstract getUserKeyBiometric(options?: StorageOptions): Promise<string>;
/**
* Checks if the user has a biometric key available
*/
abstract hasUserKeyBiometric(options?: StorageOptions): Promise<boolean>;
/**
* Sets the user's biometric key
*/
abstract setUserKeyBiometric(value: BiometricKey, options?: StorageOptions): Promise<void>;
/**
* @deprecated For backwards compatible purposes only, use DesktopAutofillSettingsService
*/
abstract setEnableDuckDuckGoBrowserIntegration(
value: boolean,
options?: StorageOptions,
): Promise<void>;
abstract getDuckDuckGoSharedKey(options?: StorageOptions): Promise<string>;
abstract setDuckDuckGoSharedKey(value: string, options?: StorageOptions): Promise<void>;
/**
* @deprecated Use `TokenService.hasAccessToken$()` or `AuthService.authStatusFor$` instead.
*/
abstract getIsAuthenticated(options?: StorageOptions): Promise<boolean>;
/**
* @deprecated Use `AccountService.activeAccount$` instead.
*/
abstract getUserId(options?: StorageOptions): Promise<string>;
}
export { StateService } from "@bitwarden/state";

View File

@@ -1,13 +0,0 @@
import { Account } from "../models/domain/account";
export class AccountFactory<T extends Account = Account> {
private accountConstructor: new (init: Partial<T>) => T;
constructor(accountConstructor: new (init: Partial<T>) => T) {
this.accountConstructor = accountConstructor;
}
create(args: Partial<T>) {
return new this.accountConstructor(args);
}
}

View File

@@ -1,15 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { GlobalState } from "../models/domain/global-state";
export class GlobalStateFactory<T extends GlobalState = GlobalState> {
private globalStateConstructor: new (init: Partial<T>) => T;
constructor(globalStateConstructor: new (init: Partial<T>) => T) {
this.globalStateConstructor = globalStateConstructor;
}
create(args?: Partial<T>) {
return new this.globalStateConstructor(args);
}
}

View File

@@ -1,29 +0,0 @@
import { Account } from "../models/domain/account";
import { GlobalState } from "../models/domain/global-state";
import { AccountFactory } from "./account-factory";
import { GlobalStateFactory } from "./global-state-factory";
export class StateFactory<
TGlobal extends GlobalState = GlobalState,
TAccount extends Account = Account,
> {
private globalStateFactory: GlobalStateFactory<TGlobal>;
private accountFactory: AccountFactory<TAccount>;
constructor(
globalStateConstructor: new (init: Partial<TGlobal>) => TGlobal,
accountConstructor: new (init: Partial<TAccount>) => TAccount,
) {
this.globalStateFactory = new GlobalStateFactory(globalStateConstructor);
this.accountFactory = new AccountFactory(accountConstructor);
}
createGlobal(args: Partial<TGlobal>): TGlobal {
return this.globalStateFactory.create(args);
}
createAccount(args: Partial<TAccount>): TAccount {
return this.accountFactory.create(args);
}
}

View File

@@ -1,42 +0,0 @@
import { makeStaticByteArray } from "../../../../spec";
import { Utils } from "../../misc/utils";
import { AccountKeys, EncryptionPair } from "./account";
describe("AccountKeys", () => {
describe("toJSON", () => {
it("should serialize itself", () => {
const keys = new AccountKeys();
const buffer = makeStaticByteArray(64);
keys.publicKey = buffer;
const bufferSpy = jest.spyOn(Utils, "fromBufferToByteString");
keys.toJSON();
expect(bufferSpy).toHaveBeenCalledWith(buffer);
});
it("should serialize public key as a string", () => {
const keys = new AccountKeys();
keys.publicKey = Utils.fromByteStringToArray("hello");
const json = JSON.stringify(keys);
expect(json).toContain('"publicKey":"hello"');
});
});
describe("fromJSON", () => {
it("should deserialize public key to a buffer", () => {
const keys = AccountKeys.fromJSON({
publicKey: "hello",
});
expect(keys.publicKey).toEqual(Utils.fromByteStringToArray("hello"));
});
it("should deserialize privateKey", () => {
const spy = jest.spyOn(EncryptionPair, "fromJSON");
AccountKeys.fromJSON({
privateKey: { encrypted: "encrypted", decrypted: "decrypted" },
} as any);
expect(spy).toHaveBeenCalled();
});
});
});

View File

@@ -1,9 +0,0 @@
import { AccountProfile } from "./account";
describe("AccountProfile", () => {
describe("fromJSON", () => {
it("should deserialize to an instance of itself", () => {
expect(AccountProfile.fromJSON({})).toBeInstanceOf(AccountProfile);
});
});
});

View File

@@ -1,19 +0,0 @@
import { Account, AccountKeys, AccountProfile } from "./account";
describe("Account", () => {
describe("fromJSON", () => {
it("should deserialize to an instance of itself", () => {
expect(Account.fromJSON({})).toBeInstanceOf(Account);
});
it("should call all the sub-fromJSONs", () => {
const keysSpy = jest.spyOn(AccountKeys, "fromJSON");
const profileSpy = jest.spyOn(AccountProfile, "fromJSON");
Account.fromJSON({});
expect(keysSpy).toHaveBeenCalled();
expect(profileSpy).toHaveBeenCalled();
});
});
});

View File

@@ -1,136 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { DeepJsonify } from "../../../types/deep-jsonify";
import { Utils } from "../../misc/utils";
import { SymmetricCryptoKey } from "./symmetric-crypto-key";
export class EncryptionPair<TEncrypted, TDecrypted> {
encrypted?: TEncrypted;
decrypted?: TDecrypted;
toJSON() {
return {
encrypted: this.encrypted,
decrypted:
this.decrypted instanceof ArrayBuffer
? Utils.fromBufferToByteString(this.decrypted)
: this.decrypted,
};
}
static fromJSON<TEncrypted, TDecrypted>(
obj: { encrypted?: Jsonify<TEncrypted>; decrypted?: string | Jsonify<TDecrypted> },
decryptedFromJson?: (decObj: Jsonify<TDecrypted> | string) => TDecrypted,
encryptedFromJson?: (encObj: Jsonify<TEncrypted>) => TEncrypted,
) {
if (obj == null) {
return null;
}
const pair = new EncryptionPair<TEncrypted, TDecrypted>();
if (obj?.encrypted != null) {
pair.encrypted = encryptedFromJson
? encryptedFromJson(obj.encrypted)
: (obj.encrypted as TEncrypted);
}
if (obj?.decrypted != null) {
pair.decrypted = decryptedFromJson
? decryptedFromJson(obj.decrypted)
: (obj.decrypted as TDecrypted);
}
return pair;
}
}
export class AccountKeys {
publicKey?: Uint8Array;
/** @deprecated July 2023, left for migration purposes*/
cryptoSymmetricKey?: EncryptionPair<string, SymmetricCryptoKey> = new EncryptionPair<
string,
SymmetricCryptoKey
>();
toJSON() {
// If you pass undefined into fromBufferToByteString, you will get an empty string back
// which will cause all sorts of headaches down the line when you try to getPublicKey
// and expect a Uint8Array and get an empty string instead.
return Utils.merge(this, {
publicKey: this.publicKey ? Utils.fromBufferToByteString(this.publicKey) : undefined,
});
}
static fromJSON(obj: DeepJsonify<AccountKeys>): AccountKeys {
if (obj == null) {
return null;
}
return Object.assign(new AccountKeys(), obj, {
cryptoSymmetricKey: EncryptionPair.fromJSON(
obj?.cryptoSymmetricKey,
SymmetricCryptoKey.fromJSON,
),
publicKey: Utils.fromByteStringToArray(obj?.publicKey),
});
}
static initRecordEncryptionPairsFromJSON(obj: any) {
return EncryptionPair.fromJSON(obj, (decObj: any) => {
if (obj == null) {
return null;
}
const record: Record<string, SymmetricCryptoKey> = {};
for (const id in decObj) {
record[id] = SymmetricCryptoKey.fromJSON(decObj[id]);
}
return record;
});
}
}
export class AccountProfile {
name?: string;
email?: string;
emailVerified?: boolean;
userId?: string;
static fromJSON(obj: Jsonify<AccountProfile>): AccountProfile {
if (obj == null) {
return null;
}
return Object.assign(new AccountProfile(), obj);
}
}
export class Account {
keys?: AccountKeys = new AccountKeys();
profile?: AccountProfile = new AccountProfile();
constructor(init: Partial<Account>) {
Object.assign(this, {
keys: {
...new AccountKeys(),
...init?.keys,
},
profile: {
...new AccountProfile(),
...init?.profile,
},
});
}
static fromJSON(json: Jsonify<Account>): Account {
if (json == null) {
return null;
}
return Object.assign(new Account({}), json, {
keys: AccountKeys.fromJSON(json?.keys),
profile: AccountProfile.fromJSON(json?.profile),
});
}
}

View File

@@ -1,41 +0,0 @@
import { Utils } from "../../misc/utils";
import { EncryptionPair } from "./account";
describe("EncryptionPair", () => {
describe("toJSON", () => {
it("should populate decryptedSerialized for buffer arrays", () => {
const pair = new EncryptionPair<string, ArrayBuffer>();
pair.decrypted = Utils.fromByteStringToArray("hello").buffer;
const json = pair.toJSON();
expect(json.decrypted).toEqual("hello");
});
it("should populate decryptedSerialized for TypesArrays", () => {
const pair = new EncryptionPair<string, Uint8Array>();
pair.decrypted = Utils.fromByteStringToArray("hello");
const json = pair.toJSON();
expect(json.decrypted).toEqual(new Uint8Array([104, 101, 108, 108, 111]));
});
it("should serialize encrypted and decrypted", () => {
const pair = new EncryptionPair<string, string>();
pair.encrypted = "hello";
pair.decrypted = "world";
const json = pair.toJSON();
expect(json.encrypted).toEqual("hello");
expect(json.decrypted).toEqual("world");
});
});
describe("fromJSON", () => {
it("should deserialize encrypted and decrypted", () => {
const pair = EncryptionPair.fromJSON({
encrypted: "hello",
decrypted: "world",
});
expect(pair.encrypted).toEqual("hello");
expect(pair.decrypted).toEqual("world");
});
});
});

View File

@@ -1,31 +0,0 @@
import { Account } from "./account";
import { State } from "./state";
describe("state", () => {
describe("fromJSON", () => {
it("should deserialize to an instance of itself", () => {
expect(State.fromJSON({}, () => new Account({}))).toBeInstanceOf(State);
});
it("should always assign an object to accounts", () => {
const state = State.fromJSON({}, () => new Account({}));
expect(state.accounts).not.toBeNull();
expect(state.accounts).toEqual({});
});
it("should build an account map", () => {
const accountsSpy = jest.spyOn(Account, "fromJSON");
const state = State.fromJSON(
{
accounts: {
userId: {},
},
},
Account.fromJSON,
);
expect(state.accounts["userId"]).toBeInstanceOf(Account);
expect(accountsSpy).toHaveBeenCalled();
});
});
});

View File

@@ -1,46 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { Account } from "./account";
import { GlobalState } from "./global-state";
export class State<
TGlobalState extends GlobalState = GlobalState,
TAccount extends Account = Account,
> {
accounts: { [userId: string]: TAccount } = {};
globals: TGlobalState;
constructor(globals: TGlobalState) {
this.globals = globals;
}
// TODO, make Jsonify<State,TGlobalState,TAccount> work. It currently doesn't because Globals doesn't implement Jsonify.
static fromJSON<TGlobalState extends GlobalState, TAccount extends Account>(
obj: any,
accountDeserializer: (json: Jsonify<TAccount>) => TAccount,
): State<TGlobalState, TAccount> {
if (obj == null) {
return null;
}
return Object.assign(new State(null), obj, {
accounts: State.buildAccountMapFromJSON(obj?.accounts, accountDeserializer),
});
}
private static buildAccountMapFromJSON<TAccount extends Account>(
jsonAccounts: { [userId: string]: Jsonify<TAccount> },
accountDeserializer: (json: Jsonify<TAccount>) => TAccount,
) {
if (!jsonAccounts) {
return {};
}
const accounts: { [userId: string]: TAccount } = {};
for (const userId in jsonAccounts) {
accounts[userId] = accountDeserializer(jsonAccounts[userId]);
}
return accounts;
}
}

View File

@@ -1,659 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map } from "rxjs";
import { Jsonify, JsonValue } from "type-fest";
import { AccountService } from "../../auth/abstractions/account.service";
import { TokenService } from "../../auth/abstractions/token.service";
import { BiometricKey } from "../../auth/types/biometric-key";
import { UserId } from "../../types/guid";
import { EnvironmentService } from "../abstractions/environment.service";
import { LogService } from "../abstractions/log.service";
import {
InitOptions,
StateService as StateServiceAbstraction,
} from "../abstractions/state.service";
import { AbstractStorageService } from "../abstractions/storage.service";
import { HtmlStorageLocation, StorageLocation } from "../enums";
import { StateFactory } from "../factories/state-factory";
import { Account } from "../models/domain/account";
import { GlobalState } from "../models/domain/global-state";
import { State } from "../models/domain/state";
import { StorageOptions } from "../models/domain/storage-options";
import { MigrationRunner } from "./migration-runner";
const keys = {
state: "state",
stateVersion: "stateVersion",
global: "global",
tempAccountSettings: "tempAccountSettings", // used to hold account specific settings (i.e clear clipboard) between initial migration and first account authentication
};
const partialKeys = {
userAutoKey: "_user_auto",
userBiometricKey: "_user_biometric",
autoKey: "_masterkey_auto",
masterKey: "_masterkey",
};
const DDG_SHARED_KEY = "DuckDuckGoSharedKey";
export class StateService<
TGlobalState extends GlobalState = GlobalState,
TAccount extends Account = Account,
> implements StateServiceAbstraction<TAccount>
{
private hasBeenInited = false;
protected isRecoveredSession = false;
// default account serializer, must be overridden by child class
protected accountDeserializer = Account.fromJSON as (json: Jsonify<TAccount>) => TAccount;
constructor(
protected storageService: AbstractStorageService,
protected secureStorageService: AbstractStorageService,
protected memoryStorageService: AbstractStorageService,
protected logService: LogService,
protected stateFactory: StateFactory<TGlobalState, TAccount>,
protected accountService: AccountService,
protected environmentService: EnvironmentService,
protected tokenService: TokenService,
private migrationRunner: MigrationRunner,
) {}
async init(initOptions: InitOptions = {}): Promise<void> {
// Deconstruct and apply defaults
const { runMigrations = true } = initOptions;
if (this.hasBeenInited) {
return;
}
if (runMigrations) {
await this.migrationRunner.run();
} else {
// It may have been requested to not run the migrations but we should defensively not
// continue this method until migrations have a chance to be completed elsewhere.
await this.migrationRunner.waitForCompletion();
}
await this.state().then(async (state) => {
if (state == null) {
await this.setState(new State<TGlobalState, TAccount>(this.createGlobals()));
} else {
this.isRecoveredSession = true;
}
});
await this.initAccountState();
this.hasBeenInited = true;
}
async initAccountState() {
if (this.isRecoveredSession) {
return;
}
// Get all likely authenticated accounts
const authenticatedAccounts = await firstValueFrom(
this.accountService.accounts$.pipe(map((accounts) => Object.keys(accounts))),
);
await this.updateState(async (state) => {
for (const i in authenticatedAccounts) {
state = await this.syncAccountFromDisk(authenticatedAccounts[i]);
}
return state;
});
}
async syncAccountFromDisk(userId: string): Promise<State<TGlobalState, TAccount>> {
if (userId == null) {
return;
}
const diskAccount = await this.getAccountFromDisk({ userId: userId });
const state = await this.updateState(async (state) => {
if (state.accounts == null) {
state.accounts = {};
}
state.accounts[userId] = this.createAccount();
if (diskAccount == null) {
// Return early because we can't set the diskAccount.profile
// if diskAccount itself is null
return state;
}
state.accounts[userId].profile = diskAccount.profile;
return state;
});
return state;
}
async addAccount(account: TAccount) {
await this.updateState(async (state) => {
state.accounts[account.profile.userId] = account;
return state;
});
await this.scaffoldNewAccountStorage(account);
}
async clean(options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
await this.deAuthenticateAccount(options.userId);
await this.removeAccountFromDisk(options?.userId);
await this.removeAccountFromMemory(options?.userId);
}
/**
* user key when using the "never" option of vault timeout
*/
async getUserKeyAutoUnlock(options?: StorageOptions): Promise<string> {
options = this.reconcileOptions(
this.reconcileOptions(options, { keySuffix: "auto" }),
await this.defaultSecureStorageOptions(),
);
if (options?.userId == null) {
return null;
}
return await this.secureStorageService.get<string>(
`${options.userId}${partialKeys.userAutoKey}`,
options,
);
}
/**
* user key when using the "never" option of vault timeout
*/
async setUserKeyAutoUnlock(value: string | null, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(
this.reconcileOptions(options, { keySuffix: "auto" }),
await this.defaultSecureStorageOptions(),
);
if (options?.userId == null) {
return;
}
await this.saveSecureStorageKey(partialKeys.userAutoKey, value, options);
}
/**
* User's encrypted symmetric key when using biometrics
*/
async getUserKeyBiometric(options?: StorageOptions): Promise<string> {
options = this.reconcileOptions(
this.reconcileOptions(options, { keySuffix: "biometric" }),
await this.defaultSecureStorageOptions(),
);
if (options?.userId == null) {
return null;
}
return await this.secureStorageService.get<string>(
`${options.userId}${partialKeys.userBiometricKey}`,
options,
);
}
async hasUserKeyBiometric(options?: StorageOptions): Promise<boolean> {
options = this.reconcileOptions(
this.reconcileOptions(options, { keySuffix: "biometric" }),
await this.defaultSecureStorageOptions(),
);
if (options?.userId == null) {
return false;
}
return await this.secureStorageService.has(
`${options.userId}${partialKeys.userBiometricKey}`,
options,
);
}
async setUserKeyBiometric(value: BiometricKey, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(
this.reconcileOptions(options, { keySuffix: "biometric" }),
await this.defaultSecureStorageOptions(),
);
if (options?.userId == null) {
return;
}
await this.saveSecureStorageKey(partialKeys.userBiometricKey, value, options);
}
async getDuckDuckGoSharedKey(options?: StorageOptions): Promise<string> {
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
if (options?.userId == null) {
return null;
}
return await this.secureStorageService.get<string>(DDG_SHARED_KEY, options);
}
async setDuckDuckGoSharedKey(value: string, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
if (options?.userId == null) {
return;
}
value == null
? await this.secureStorageService.remove(DDG_SHARED_KEY, options)
: await this.secureStorageService.save(DDG_SHARED_KEY, value, options);
}
async setEnableDuckDuckGoBrowserIntegration(
value: boolean,
options?: StorageOptions,
): Promise<void> {
const globals = await this.getGlobals(
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
globals.enableDuckDuckGoBrowserIntegration = value;
await this.saveGlobals(
globals,
this.reconcileOptions(options, await this.defaultOnDiskOptions()),
);
}
/**
* @deprecated Use UserKey instead
*/
async getEncryptedCryptoSymmetricKey(options?: StorageOptions): Promise<string> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.keys.cryptoSymmetricKey.encrypted;
}
async getIsAuthenticated(options?: StorageOptions): Promise<boolean> {
return (
(await this.tokenService.getAccessToken(options?.userId as UserId)) != null &&
(await this.getUserId(options)) != null
);
}
async getUserId(options?: StorageOptions): Promise<string> {
return (
await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))
)?.profile?.userId;
}
protected async getGlobals(options: StorageOptions): Promise<TGlobalState> {
let globals: TGlobalState;
if (this.useMemory(options.storageLocation)) {
globals = await this.getGlobalsFromMemory();
}
if (this.useDisk && globals == null) {
globals = await this.getGlobalsFromDisk(options);
}
if (globals == null) {
globals = this.createGlobals();
}
return globals;
}
protected async saveGlobals(globals: TGlobalState, options: StorageOptions) {
return this.useMemory(options.storageLocation)
? this.saveGlobalsToMemory(globals)
: await this.saveGlobalsToDisk(globals, options);
}
protected async getGlobalsFromMemory(): Promise<TGlobalState> {
return (await this.state()).globals;
}
protected async getGlobalsFromDisk(options: StorageOptions): Promise<TGlobalState> {
return await this.storageService.get<TGlobalState>(keys.global, options);
}
protected async saveGlobalsToMemory(globals: TGlobalState): Promise<void> {
await this.updateState(async (state) => {
state.globals = globals;
return state;
});
}
protected async saveGlobalsToDisk(globals: TGlobalState, options: StorageOptions): Promise<void> {
if (options.useSecureStorage) {
await this.secureStorageService.save(keys.global, globals, options);
} else {
await this.storageService.save(keys.global, globals, options);
}
}
protected async getAccount(options: StorageOptions): Promise<TAccount> {
try {
let account: TAccount;
if (this.useMemory(options.storageLocation)) {
account = await this.getAccountFromMemory(options);
}
if (this.useDisk(options.storageLocation) && account == null) {
account = await this.getAccountFromDisk(options);
}
return account;
} catch (e) {
this.logService.error(e);
}
}
protected async getAccountFromMemory(options: StorageOptions): Promise<TAccount> {
const userId =
options.userId ??
(await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
));
return await this.state().then(async (state) => {
if (state.accounts == null) {
return null;
}
return state.accounts[userId];
});
}
protected async getAccountFromDisk(options: StorageOptions): Promise<TAccount> {
const userId =
options.userId ??
(await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
));
if (userId == null) {
return null;
}
const account = options?.useSecureStorage
? ((await this.secureStorageService.get<TAccount>(options.userId, options)) ??
(await this.storageService.get<TAccount>(
options.userId,
this.reconcileOptions(options, { htmlStorageLocation: HtmlStorageLocation.Local }),
)))
: await this.storageService.get<TAccount>(options.userId, options);
return account;
}
protected useMemory(storageLocation: StorageLocation) {
return storageLocation === StorageLocation.Memory || storageLocation === StorageLocation.Both;
}
protected useDisk(storageLocation: StorageLocation) {
return storageLocation === StorageLocation.Disk || storageLocation === StorageLocation.Both;
}
protected async saveAccount(
account: TAccount,
options: StorageOptions = {
storageLocation: StorageLocation.Both,
useSecureStorage: false,
},
) {
return this.useMemory(options.storageLocation)
? await this.saveAccountToMemory(account)
: await this.saveAccountToDisk(account, options);
}
protected async saveAccountToDisk(account: TAccount, options: StorageOptions): Promise<void> {
const storageLocation = options.useSecureStorage
? this.secureStorageService
: this.storageService;
await storageLocation.save(`${options.userId}`, account, options);
}
protected async saveAccountToMemory(account: TAccount): Promise<void> {
if ((await this.getAccountFromMemory({ userId: account.profile.userId })) !== null) {
await this.updateState((state) => {
return new Promise((resolve) => {
state.accounts[account.profile.userId] = account;
resolve(state);
});
});
}
}
protected async scaffoldNewAccountStorage(account: TAccount): Promise<void> {
// We don't want to manipulate the referenced in memory account
const deepClone = JSON.parse(JSON.stringify(account));
await this.scaffoldNewAccountLocalStorage(deepClone);
await this.scaffoldNewAccountSessionStorage(deepClone);
await this.scaffoldNewAccountMemoryStorage(deepClone);
}
// TODO: There is a tech debt item for splitting up these methods - only Web uses multiple storage locations in its storageService.
// For now these methods exist with some redundancy to facilitate this special web requirement.
protected async scaffoldNewAccountLocalStorage(account: TAccount): Promise<void> {
await this.saveAccount(
account,
this.reconcileOptions(
{ userId: account.profile.userId },
await this.defaultOnDiskLocalOptions(),
),
);
}
protected async scaffoldNewAccountMemoryStorage(account: TAccount): Promise<void> {
await this.storageService.save(
account.profile.userId,
account,
await this.defaultOnDiskMemoryOptions(),
);
await this.saveAccount(
account,
this.reconcileOptions(
{ userId: account.profile.userId },
await this.defaultOnDiskMemoryOptions(),
),
);
}
protected async scaffoldNewAccountSessionStorage(account: TAccount): Promise<void> {
await this.storageService.save(
account.profile.userId,
account,
await this.defaultOnDiskMemoryOptions(),
);
await this.saveAccount(
account,
this.reconcileOptions({ userId: account.profile.userId }, await this.defaultOnDiskOptions()),
);
}
protected reconcileOptions(
requestedOptions: StorageOptions,
defaultOptions: StorageOptions,
): StorageOptions {
if (requestedOptions == null) {
return defaultOptions;
}
requestedOptions.userId = requestedOptions?.userId ?? defaultOptions.userId;
requestedOptions.storageLocation =
requestedOptions?.storageLocation ?? defaultOptions.storageLocation;
requestedOptions.useSecureStorage =
requestedOptions?.useSecureStorage ?? defaultOptions.useSecureStorage;
requestedOptions.htmlStorageLocation =
requestedOptions?.htmlStorageLocation ?? defaultOptions.htmlStorageLocation;
requestedOptions.keySuffix = requestedOptions?.keySuffix ?? defaultOptions.keySuffix;
return requestedOptions;
}
protected async defaultInMemoryOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return {
storageLocation: StorageLocation.Memory,
userId,
};
}
protected async defaultOnDiskOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return {
storageLocation: StorageLocation.Disk,
htmlStorageLocation: HtmlStorageLocation.Session,
userId,
useSecureStorage: false,
};
}
protected async defaultOnDiskLocalOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return {
storageLocation: StorageLocation.Disk,
htmlStorageLocation: HtmlStorageLocation.Local,
userId,
useSecureStorage: false,
};
}
protected async defaultOnDiskMemoryOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return {
storageLocation: StorageLocation.Disk,
htmlStorageLocation: HtmlStorageLocation.Memory,
userId,
useSecureStorage: false,
};
}
protected async defaultSecureStorageOptions(): Promise<StorageOptions> {
const userId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
return {
storageLocation: StorageLocation.Disk,
useSecureStorage: true,
userId,
};
}
protected async getActiveUserIdFromStorage(): Promise<string> {
return await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id)));
}
protected async removeAccountFromLocalStorage(userId: string = null): Promise<void> {
userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
const storedAccount = await this.getAccount(
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()),
);
await this.saveAccount(
this.resetAccount(storedAccount),
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()),
);
}
protected async removeAccountFromSessionStorage(userId: string = null): Promise<void> {
userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
const storedAccount = await this.getAccount(
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()),
);
await this.saveAccount(
this.resetAccount(storedAccount),
this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()),
);
}
protected async removeAccountFromSecureStorage(userId: string = null): Promise<void> {
userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
await this.setUserKeyAutoUnlock(null, { userId: userId });
await this.setUserKeyBiometric(null, { userId: userId });
}
protected async removeAccountFromMemory(userId: string = null): Promise<void> {
userId ??= await firstValueFrom(
this.accountService.activeAccount$.pipe(map((account) => account?.id)),
);
await this.updateState(async (state) => {
delete state.accounts[userId];
return state;
});
}
// settings persist even on reset, and are not affected by this method
protected resetAccount(account: TAccount) {
// All settings have been moved to StateProviders
return this.createAccount();
}
protected createAccount(init: Partial<TAccount> = null): TAccount {
return this.stateFactory.createAccount(init);
}
protected createGlobals(init: Partial<TGlobalState> = null): TGlobalState {
return this.stateFactory.createGlobal(init);
}
protected async deAuthenticateAccount(userId: string): Promise<void> {
// We must have a manual call to clear tokens as we can't leverage state provider to clean
// up our data as we have secure storage in the mix.
await this.tokenService.clearTokens(userId as UserId);
}
protected async removeAccountFromDisk(userId: string) {
await this.removeAccountFromSessionStorage(userId);
await this.removeAccountFromLocalStorage(userId);
await this.removeAccountFromSecureStorage(userId);
}
protected async saveSecureStorageKey<T extends JsonValue>(
key: string,
value: T | null,
options?: StorageOptions,
) {
return value == null
? await this.secureStorageService.remove(`${options.userId}${key}`, options)
: await this.secureStorageService.save(`${options.userId}${key}`, value, options);
}
protected async state(): Promise<State<TGlobalState, TAccount>> {
let state = await this.memoryStorageService.get<State<TGlobalState, TAccount>>(keys.state);
if (this.memoryStorageService.valuesRequireDeserialization) {
state = State.fromJSON(state, this.accountDeserializer);
}
return state;
}
private async setState(
state: State<TGlobalState, TAccount>,
): Promise<State<TGlobalState, TAccount>> {
await this.memoryStorageService.save(keys.state, state);
return state;
}
protected async updateState(
stateUpdater: (state: State<TGlobalState, TAccount>) => Promise<State<TGlobalState, TAccount>>,
): Promise<State<TGlobalState, TAccount>> {
return await this.state().then(async (state) => {
const updatedState = await stateUpdater(state);
if (updatedState == null) {
throw new Error("Attempted to update state to null value");
}
return await this.setState(updatedState);
});
}
}

View File

@@ -9,6 +9,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
import { ApiService } from "../../abstractions/api.service";
import { AccountService } from "../../auth/abstractions/account.service";
import { AuthService } from "../../auth/abstractions/auth.service";
import { TokenService } from "../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../auth/enums/authentication-status";
import {
SyncCipherNotification,
@@ -26,7 +27,6 @@ import { SyncService } from "../../vault/abstractions/sync/sync.service.abstract
import { CipherData } from "../../vault/models/data/cipher.data";
import { FolderData } from "../../vault/models/data/folder.data";
import { LogService } from "../abstractions/log.service";
import { StateService } from "../abstractions/state.service";
import { MessageSender } from "../messaging";
import { StateProvider, SYNC_DISK, UserKeyDefinition } from "../state";
@@ -44,7 +44,7 @@ export abstract class CoreSyncService implements SyncService {
syncInProgress = false;
constructor(
protected readonly stateService: StateService,
readonly tokenService: TokenService,
protected readonly folderService: InternalFolderService,
protected readonly folderApiService: FolderApiServiceAbstraction,
protected readonly messageSender: MessageSender,
@@ -256,7 +256,13 @@ export abstract class CoreSyncService implements SyncService {
async syncDeleteSend(notification: SyncSendNotification): Promise<boolean> {
this.syncStarted();
if (await this.stateService.getIsAuthenticated()) {
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
if (
activeUserId != null &&
(await firstValueFrom(this.tokenService.hasAccessToken$(activeUserId)))
) {
await this.sendService.delete(notification.id);
this.messageSender.send("syncedDeletedSend", { sendId: notification.id });
// TODO: Update syncCompleted userId when send service allows modification of non-active users

View File

@@ -36,7 +36,6 @@ import { CipherService } from "../../vault/abstractions/cipher.service";
import { FolderApiServiceAbstraction } from "../../vault/abstractions/folder/folder-api.service.abstraction";
import { InternalFolderService } from "../../vault/abstractions/folder/folder.service.abstraction";
import { LogService } from "../abstractions/log.service";
import { StateService } from "../abstractions/state.service";
import { MessageSender } from "../messaging";
import { StateProvider } from "../state";
@@ -57,7 +56,6 @@ describe("DefaultSyncService", () => {
let sendService: MockProxy<InternalSendService>;
let logService: MockProxy<LogService>;
let keyConnectorService: MockProxy<KeyConnectorService>;
let stateService: MockProxy<StateService>;
let providerService: MockProxy<ProviderService>;
let folderApiService: MockProxy<FolderApiServiceAbstraction>;
let organizationService: MockProxy<InternalOrganizationServiceAbstraction>;
@@ -86,7 +84,6 @@ describe("DefaultSyncService", () => {
sendService = mock();
logService = mock();
keyConnectorService = mock();
stateService = mock();
providerService = mock();
folderApiService = mock();
organizationService = mock();
@@ -113,7 +110,6 @@ describe("DefaultSyncService", () => {
sendService,
logService,
keyConnectorService,
stateService,
providerService,
folderApiService,
organizationService,

View File

@@ -53,7 +53,6 @@ import { FolderData } from "../../vault/models/data/folder.data";
import { CipherResponse } from "../../vault/models/response/cipher.response";
import { FolderResponse } from "../../vault/models/response/folder.response";
import { LogService } from "../abstractions/log.service";
import { StateService } from "../abstractions/state.service";
import { MessageSender } from "../messaging";
import { StateProvider } from "../state";
@@ -87,7 +86,6 @@ export class DefaultSyncService extends CoreSyncService {
sendService: InternalSendService,
logService: LogService,
private keyConnectorService: KeyConnectorService,
stateService: StateService,
private providerService: ProviderService,
folderApiService: FolderApiServiceAbstraction,
private organizationService: InternalOrganizationServiceAbstraction,
@@ -96,12 +94,12 @@ export class DefaultSyncService extends CoreSyncService {
private avatarService: AvatarService,
private logoutCallback: (logoutReason: LogoutReason, userId?: UserId) => Promise<void>,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private tokenService: TokenService,
tokenService: TokenService,
authService: AuthService,
stateProvider: StateProvider,
) {
super(
stateService,
tokenService,
folderService,
folderApiService,
messageSender,

View File

@@ -20,7 +20,6 @@ import { EncString } from "../../key-management/crypto/models/enc-string";
import { UriMatchStrategy } from "../../models/domain/domain-service";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
@@ -94,7 +93,6 @@ let accountService: FakeAccountService;
describe("Cipher Service", () => {
const keyService = mock<KeyService>();
const stateService = mock<StateService>();
const autofillSettingsService = mock<AutofillSettingsService>();
const domainSettingsService = mock<DomainSettingsService>();
const apiService = mock<ApiService>();
@@ -127,7 +125,6 @@ describe("Cipher Service", () => {
apiService,
i18nService,
searchService,
stateService,
autofillSettingsService,
encryptService,
cipherFileUploadService,
@@ -470,8 +467,6 @@ describe("Cipher Service", () => {
searchService.indexedEntityId$.mockReturnValue(of(null));
stateService.getUserId.mockResolvedValue(mockUserId);
const keys = { userKey: originalUserKey } as CipherDecryptionKeys;
keyService.cipherDecryptionKeys$.mockReturnValue(of(keys));

View File

@@ -31,7 +31,6 @@ import { ListResponse } from "../../models/response/list.response";
import { View } from "../../models/view/view";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { StateService } from "../../platform/abstractions/state.service";
import { Utils } from "../../platform/misc/utils";
import Domain from "../../platform/models/domain/domain-base";
import { EncArrayBuffer } from "../../platform/models/domain/enc-array-buffer";
@@ -110,7 +109,6 @@ export class CipherService implements CipherServiceAbstraction {
private apiService: ApiService,
private i18nService: I18nService,
private searchService: SearchService,
private stateService: StateService,
private autofillSettingsService: AutofillSettingsServiceAbstraction,
private encryptService: EncryptService,
private cipherFileUploadService: CipherFileUploadService,

View File

@@ -732,7 +732,7 @@ export class DefaultKeyService implements KeyServiceAbstraction {
protected async getKeyFromStorage(
keySuffix: KeySuffixOptions,
userId?: UserId,
userId: UserId,
): Promise<UserKey | null> {
if (keySuffix === KeySuffixOptions.Auto) {
const userKey = await this.stateService.getUserKeyAutoUnlock({ userId: userId });

View File

@@ -2,3 +2,4 @@
export * from "./core";
export * from "./state-migrations";
export * from "./types/state";
export * from "./legacy";

View File

@@ -0,0 +1,107 @@
import { firstValueFrom } from "rxjs";
import { StorageService } from "@bitwarden/storage-core";
import { UserId } from "@bitwarden/user-core";
import { ActiveUserAccessor } from "../core";
import { GlobalState } from "./global-state";
import { RequiredUserId, StateService } from "./state.service";
const keys = {
global: "global",
};
const partialKeys = {
userAutoKey: "_user_auto",
userBiometricKey: "_user_biometric",
};
const DDG_SHARED_KEY = "DuckDuckGoSharedKey";
export class DefaultStateService implements StateService {
constructor(
private readonly storageService: StorageService,
private readonly secureStorageService: StorageService,
private readonly activeUserAccessor: ActiveUserAccessor,
) {}
async clean(options: RequiredUserId): Promise<void> {
await this.setUserKeyAutoUnlock(null, options);
await this.clearUserKeyBiometric(options.userId);
}
/**
* user key when using the "never" option of vault timeout
*/
async getUserKeyAutoUnlock(options: RequiredUserId): Promise<string | null> {
if (options.userId == null) {
return null;
}
return await this.secureStorageService.get<string>(
`${options.userId}${partialKeys.userAutoKey}`,
{
userId: options.userId,
keySuffix: "auto",
},
);
}
/**
* user key when using the "never" option of vault timeout
*/
async setUserKeyAutoUnlock(value: string | null, options: RequiredUserId): Promise<void> {
if (options.userId == null) {
return;
}
await this.saveSecureStorageKey(partialKeys.userAutoKey, value, options.userId, "auto");
}
private async clearUserKeyBiometric(userId: UserId): Promise<void> {
if (userId == null) {
return;
}
await this.saveSecureStorageKey(partialKeys.userBiometricKey, null, userId, "biometric");
}
async getDuckDuckGoSharedKey(): Promise<string | null> {
const userId = await this.getActiveUserIdFromStorage();
if (userId == null) {
return null;
}
return await this.secureStorageService.get<string>(DDG_SHARED_KEY);
}
async setDuckDuckGoSharedKey(value: string): Promise<void> {
const userId = await this.getActiveUserIdFromStorage();
if (userId == null) {
return;
}
value == null
? await this.secureStorageService.remove(DDG_SHARED_KEY)
: await this.secureStorageService.save(DDG_SHARED_KEY, value);
}
async setEnableDuckDuckGoBrowserIntegration(value: boolean): Promise<void> {
const globals = (await this.storageService.get<GlobalState>(keys.global)) ?? new GlobalState();
globals.enableDuckDuckGoBrowserIntegration = value;
await this.storageService.save(keys.global, globals);
}
private async getActiveUserIdFromStorage(): Promise<UserId | null> {
return await firstValueFrom(this.activeUserAccessor.activeUserId$);
}
private async saveSecureStorageKey(
key: string,
value: string | null,
userId: UserId,
keySuffix: string,
) {
return value == null
? await this.secureStorageService.remove(`${userId}${key}`, { keySuffix: keySuffix })
: await this.secureStorageService.save(`${userId}${key}`, value, {
keySuffix: keySuffix,
});
}
}

View File

@@ -0,0 +1,2 @@
export { StateService } from "./state.service";
export { DefaultStateService } from "./default-state.service";

View File

@@ -0,0 +1,25 @@
import { UserId } from "@bitwarden/user-core";
export type RequiredUserId = { userId: UserId };
/**
* This class exists for various legacy reasons, there are likely better things to use than this service.
*/
export abstract class StateService {
abstract clean(options: RequiredUserId): Promise<void>;
/**
* Gets the user's auto key
*/
abstract getUserKeyAutoUnlock(options: RequiredUserId): Promise<string | null>;
/**
* Sets the user's auto key
*/
abstract setUserKeyAutoUnlock(value: string | null, options: RequiredUserId): Promise<void>;
/**
* @deprecated For backwards compatible purposes only, use DesktopAutofillSettingsService
*/
abstract setEnableDuckDuckGoBrowserIntegration(value: boolean): Promise<void>;
abstract getDuckDuckGoSharedKey(): Promise<string | null>;
abstract setDuckDuckGoSharedKey(value: string): Promise<void>;
}