1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 15:23:33 +00:00

Specify clearOn options for platform services (#8584)

* Use UserKeys in biometric state

* Remove global clear todo. Answer is never

* User UserKeys in crypto state

* Clear userkey on both lock and logout via User Key Definitions

* Use UserKeyDefinitions in environment service

* Rely on userKeyDefinition to clear org keys

* Rely on userKeyDefinition to clear provider keys

* Rely on userKeyDefinition to clear user keys

* Rely on userKeyDefinitions to clear user asym key pair
This commit is contained in:
Matt Gibson
2024-04-09 10:17:00 -05:00
committed by GitHub
parent aefea43fff
commit c02723d6a6
15 changed files with 169 additions and 365 deletions

View File

@@ -32,12 +32,8 @@ export class FakeAccountService implements AccountService {
get activeUserId() { get activeUserId() {
return this._activeUserId; return this._activeUserId;
} }
get accounts$() { accounts$ = this.accountsSubject.asObservable();
return this.accountsSubject.asObservable(); activeAccount$ = this.activeAccountSubject.asObservable();
}
get activeAccount$() {
return this.activeAccountSubject.asObservable();
}
accountLock$: Observable<UserId>; accountLock$: Observable<UserId>;
accountLogout$: Observable<UserId>; accountLogout$: Observable<UserId>;

View File

@@ -26,7 +26,7 @@ export abstract class CryptoService {
* any other necessary versions (such as auto, biometrics, * any other necessary versions (such as auto, biometrics,
* or pin) * or pin)
* *
* @throws when key is null. Use {@link clearUserKey} instead * @throws when key is null. Lock the account to clear a key
* @param key The user key to set * @param key The user key to set
* @param userId The desired user * @param userId The desired user
*/ */
@@ -93,13 +93,6 @@ export abstract class CryptoService {
* @returns A new user key and the master key protected version of it * @returns A new user key and the master key protected version of it
*/ */
abstract makeUserKey(key: MasterKey): Promise<[UserKey, EncString]>; abstract makeUserKey(key: MasterKey): Promise<[UserKey, EncString]>;
/**
* Clears the user key
* @param clearStoredKeys Clears all stored versions of the user keys as well,
* such as the biometrics key
* @param userId The desired user
*/
abstract clearUserKey(clearSecretStorage?: boolean, userId?: string): Promise<void>;
/** /**
* Clears the user's stored version of the user key * Clears the user's stored version of the user key
* @param keySuffix The desired version of the key to clear * @param keySuffix The desired version of the key to clear
@@ -238,12 +231,6 @@ export abstract class CryptoService {
abstract makeDataEncKey<T extends UserKey | OrgKey>( abstract makeDataEncKey<T extends UserKey | OrgKey>(
key: T, key: T,
): Promise<[SymmetricCryptoKey, EncString]>; ): Promise<[SymmetricCryptoKey, EncString]>;
/**
* Clears the user's stored organization keys
* @param memoryOnly Clear only the in-memory keys
* @param userId The desired user
*/
abstract clearOrgKeys(memoryOnly?: boolean, userId?: string): Promise<void>;
/** /**
* Stores the encrypted provider keys and clears any decrypted * Stores the encrypted provider keys and clears any decrypted
* provider keys currently in memory * provider keys currently in memory
@@ -260,11 +247,6 @@ export abstract class CryptoService {
* @returns A record of the provider Ids to their symmetric keys * @returns A record of the provider Ids to their symmetric keys
*/ */
abstract getProviderKeys(): Promise<Record<ProviderId, ProviderKey>>; abstract getProviderKeys(): Promise<Record<ProviderId, ProviderKey>>;
/**
* @param memoryOnly Clear only the in-memory keys
* @param userId The desired user
*/
abstract clearProviderKeys(memoryOnly?: boolean, userId?: string): Promise<void>;
/** /**
* Returns the public key from memory. If not available, extracts it * Returns the public key from memory. If not available, extracts it
* from the private key and stores it in memory * from the private key and stores it in memory
@@ -304,12 +286,6 @@ export abstract class CryptoService {
* @returns A new keypair: [publicKey in Base64, encrypted privateKey] * @returns A new keypair: [publicKey in Base64, encrypted privateKey]
*/ */
abstract makeKeyPair(key?: SymmetricCryptoKey): Promise<[string, EncString]>; abstract makeKeyPair(key?: SymmetricCryptoKey): Promise<[string, EncString]>;
/**
* Clears the user's key pair
* @param memoryOnly Clear only the in-memory keys
* @param userId The desired user
*/
abstract clearKeyPair(memoryOnly?: boolean, userId?: string): Promise<void[]>;
/** /**
* @param pin The user's pin * @param pin The user's pin
* @param salt The user's salt * @param salt The user's salt

View File

@@ -1,5 +1,5 @@
import { EncryptedString } from "../models/domain/enc-string"; import { EncryptedString } from "../models/domain/enc-string";
import { KeyDefinition } from "../state"; import { KeyDefinition, UserKeyDefinition } from "../state";
import { import {
BIOMETRIC_UNLOCK_ENABLED, BIOMETRIC_UNLOCK_ENABLED,
@@ -22,9 +22,15 @@ describe.each([
])( ])(
"deserializes state %s", "deserializes state %s",
( (
...args: [KeyDefinition<EncryptedString>, EncryptedString] | [KeyDefinition<boolean>, boolean] ...args:
| [UserKeyDefinition<EncryptedString>, EncryptedString]
| [UserKeyDefinition<boolean>, boolean]
| [KeyDefinition<boolean>, boolean]
) => { ) => {
function testDeserialization<T>(keyDefinition: KeyDefinition<T>, state: T) { function testDeserialization<T>(
keyDefinition: UserKeyDefinition<T> | KeyDefinition<T>,
state: T,
) {
const deserialized = keyDefinition.deserializer(JSON.parse(JSON.stringify(state))); const deserialized = keyDefinition.deserializer(JSON.parse(JSON.stringify(state)));
expect(deserialized).toEqual(state); expect(deserialized).toEqual(state);
} }

View File

@@ -1,15 +1,16 @@
import { UserId } from "../../types/guid"; import { UserId } from "../../types/guid";
import { EncryptedString } from "../models/domain/enc-string"; import { EncryptedString } from "../models/domain/enc-string";
import { KeyDefinition, BIOMETRIC_SETTINGS_DISK } from "../state"; import { KeyDefinition, BIOMETRIC_SETTINGS_DISK, UserKeyDefinition } from "../state";
/** /**
* Indicates whether the user elected to store a biometric key to unlock their vault. * Indicates whether the user elected to store a biometric key to unlock their vault.
*/ */
export const BIOMETRIC_UNLOCK_ENABLED = new KeyDefinition<boolean>( export const BIOMETRIC_UNLOCK_ENABLED = new UserKeyDefinition<boolean>(
BIOMETRIC_SETTINGS_DISK, BIOMETRIC_SETTINGS_DISK,
"biometricUnlockEnabled", "biometricUnlockEnabled",
{ {
deserializer: (obj) => obj, deserializer: (obj) => obj,
clearOn: [],
}, },
); );
@@ -18,11 +19,12 @@ export const BIOMETRIC_UNLOCK_ENABLED = new KeyDefinition<boolean>(
* *
* A true setting controls whether {@link ENCRYPTED_CLIENT_KEY_HALF} is set. * A true setting controls whether {@link ENCRYPTED_CLIENT_KEY_HALF} is set.
*/ */
export const REQUIRE_PASSWORD_ON_START = new KeyDefinition<boolean>( export const REQUIRE_PASSWORD_ON_START = new UserKeyDefinition<boolean>(
BIOMETRIC_SETTINGS_DISK, BIOMETRIC_SETTINGS_DISK,
"requirePasswordOnStart", "requirePasswordOnStart",
{ {
deserializer: (value) => value, deserializer: (value) => value,
clearOn: [],
}, },
); );
@@ -33,11 +35,12 @@ export const REQUIRE_PASSWORD_ON_START = new KeyDefinition<boolean>(
* For operating systems without application-level key storage, this key half is concatenated with a signature * For operating systems without application-level key storage, this key half is concatenated with a signature
* provided by the OS and used to encrypt the biometric key prior to storage. * provided by the OS and used to encrypt the biometric key prior to storage.
*/ */
export const ENCRYPTED_CLIENT_KEY_HALF = new KeyDefinition<EncryptedString>( export const ENCRYPTED_CLIENT_KEY_HALF = new UserKeyDefinition<EncryptedString>(
BIOMETRIC_SETTINGS_DISK, BIOMETRIC_SETTINGS_DISK,
"clientKeyHalf", "clientKeyHalf",
{ {
deserializer: (obj) => obj, deserializer: (obj) => obj,
clearOn: ["logout"],
}, },
); );
@@ -45,11 +48,12 @@ export const ENCRYPTED_CLIENT_KEY_HALF = new KeyDefinition<EncryptedString>(
* Indicates the user has been warned about the security implications of using biometrics and, depending on the OS, * Indicates the user has been warned about the security implications of using biometrics and, depending on the OS,
* recommended to require a password on first unlock of an application instance. * recommended to require a password on first unlock of an application instance.
*/ */
export const DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT = new KeyDefinition<boolean>( export const DISMISSED_REQUIRE_PASSWORD_ON_START_CALLOUT = new UserKeyDefinition<boolean>(
BIOMETRIC_SETTINGS_DISK, BIOMETRIC_SETTINGS_DISK,
"dismissedBiometricRequirePasswordOnStartCallout", "dismissedBiometricRequirePasswordOnStartCallout",
{ {
deserializer: (obj) => obj, deserializer: (obj) => obj,
clearOn: [],
}, },
); );
@@ -68,11 +72,12 @@ export const PROMPT_CANCELLED = KeyDefinition.record<boolean, UserId>(
/** /**
* Stores whether the user has elected to automatically prompt for biometric unlock on application start. * Stores whether the user has elected to automatically prompt for biometric unlock on application start.
*/ */
export const PROMPT_AUTOMATICALLY = new KeyDefinition<boolean>( export const PROMPT_AUTOMATICALLY = new UserKeyDefinition<boolean>(
BIOMETRIC_SETTINGS_DISK, BIOMETRIC_SETTINGS_DISK,
"promptAutomatically", "promptAutomatically",
{ {
deserializer: (obj) => obj, deserializer: (obj) => obj,
clearOn: [],
}, },
); );

View File

@@ -32,7 +32,6 @@ export const USER_SERVER_CONFIG = new UserKeyDefinition<ServerConfig>(CONFIG_DIS
clearOn: ["logout"], clearOn: ["logout"],
}); });
// TODO MDG: When to clean these up?
export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record<ServerConfig, ApiUrl>( export const GLOBAL_SERVER_CONFIGURATIONS = KeyDefinition.record<ServerConfig, ApiUrl>(
CONFIG_DISK, CONFIG_DISK,
"byServer", "byServer",

View File

@@ -1,5 +1,5 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs"; import { firstValueFrom, of, tap } from "rxjs";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state"; import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state";
@@ -18,6 +18,7 @@ import { Utils } from "../misc/utils";
import { EncString } from "../models/domain/enc-string"; import { EncString } from "../models/domain/enc-string";
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
import { CryptoService } from "../services/crypto.service"; import { CryptoService } from "../services/crypto.service";
import { UserKeyDefinition } from "../state";
import { USER_ENCRYPTED_ORGANIZATION_KEYS } from "./key-state/org-keys.state"; import { USER_ENCRYPTED_ORGANIZATION_KEYS } from "./key-state/org-keys.state";
import { USER_ENCRYPTED_PROVIDER_KEYS } from "./key-state/provider-keys.state"; import { USER_ENCRYPTED_PROVIDER_KEYS } from "./key-state/provider-keys.state";
@@ -336,231 +337,22 @@ describe("cryptoService", () => {
}); });
}); });
describe("clearUserKey", () => { describe("clearKeys", () => {
it.each([mockUserId, null])("should clear the User Key for id %2", async (userId) => { it("resolves active user id when called with no user id", async () => {
await cryptoService.clearUserKey(false, userId); let callCount = 0;
accountService.activeAccount$ = accountService.activeAccountSubject.pipe(
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(USER_KEY, null, userId); tap(() => callCount++),
});
it("should update status to locked", async () => {
await cryptoService.clearUserKey(false, mockUserId);
expect(accountService.mock.setMaxAccountStatus).toHaveBeenCalledWith(
mockUserId,
AuthenticationStatus.Locked,
); );
});
it.each([true, false])( await cryptoService.clearKeys(null);
"should clear stored user keys if clearAll is true (%s)", expect(callCount).toBe(1);
async (clear) => {
const clearSpy = (cryptoService["clearAllStoredUserKeys"] = jest.fn());
await cryptoService.clearUserKey(clear, mockUserId);
if (clear) { // revert to the original state
expect(clearSpy).toHaveBeenCalledWith(mockUserId); accountService.activeAccount$ = accountService.activeAccountSubject.asObservable();
expect(clearSpy).toHaveBeenCalledTimes(1);
} else {
expect(clearSpy).not.toHaveBeenCalled();
}
},
);
});
describe("clearOrgKeys", () => {
let forceMemorySpy: jest.Mock;
beforeEach(() => {
forceMemorySpy = cryptoService["activeUserOrgKeysState"].forceValue = jest.fn();
});
it("clears in memory org keys when called with memoryOnly", async () => {
await cryptoService.clearOrgKeys(true);
expect(forceMemorySpy).toHaveBeenCalledWith({});
});
it("does not clear memory when called with the non active user and memory only", async () => {
await cryptoService.clearOrgKeys(true, "someOtherUser" as UserId);
expect(forceMemorySpy).not.toHaveBeenCalled();
});
it("does not write to disk state if called with memory only", async () => {
await cryptoService.clearOrgKeys(true);
expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalled();
});
it("clears disk state when called with diskOnly", async () => {
await cryptoService.clearOrgKeys(false);
expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith(
mockUserId,
USER_ENCRYPTED_ORGANIZATION_KEYS,
);
expect(
stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_ORGANIZATION_KEYS).nextMock,
).toHaveBeenCalledWith(null);
});
it("clears another user's disk state when called with diskOnly and that user", async () => {
await cryptoService.clearOrgKeys(false, "someOtherUser" as UserId);
expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith(
"someOtherUser" as UserId,
USER_ENCRYPTED_ORGANIZATION_KEYS,
);
expect(
stateProvider.singleUser.getFake(
"someOtherUser" as UserId,
USER_ENCRYPTED_ORGANIZATION_KEYS,
).nextMock,
).toHaveBeenCalledWith(null);
});
it("does not clear active user disk state when called with diskOnly and a different specified user", async () => {
await cryptoService.clearOrgKeys(false, "someOtherUser" as UserId);
expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalledWith(
mockUserId,
USER_ENCRYPTED_ORGANIZATION_KEYS,
);
});
});
describe("clearProviderKeys", () => {
let forceMemorySpy: jest.Mock;
beforeEach(() => {
forceMemorySpy = cryptoService["activeUserProviderKeysState"].forceValue = jest.fn();
});
it("clears in memory org keys when called with memoryOnly", async () => {
await cryptoService.clearProviderKeys(true);
expect(forceMemorySpy).toHaveBeenCalledWith({});
});
it("does not clear memory when called with the non active user and memory only", async () => {
await cryptoService.clearProviderKeys(true, "someOtherUser" as UserId);
expect(forceMemorySpy).not.toHaveBeenCalled();
});
it("does not write to disk state if called with memory only", async () => {
await cryptoService.clearProviderKeys(true);
expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalled();
});
it("clears disk state when called with diskOnly", async () => {
await cryptoService.clearProviderKeys(false);
expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith(
mockUserId,
USER_ENCRYPTED_PROVIDER_KEYS,
);
expect(
stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PROVIDER_KEYS).nextMock,
).toHaveBeenCalledWith(null);
});
it("clears another user's disk state when called with diskOnly and that user", async () => {
await cryptoService.clearProviderKeys(false, "someOtherUser" as UserId);
expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith(
"someOtherUser" as UserId,
USER_ENCRYPTED_PROVIDER_KEYS,
);
expect(
stateProvider.singleUser.getFake("someOtherUser" as UserId, USER_ENCRYPTED_PROVIDER_KEYS)
.nextMock,
).toHaveBeenCalledWith(null);
});
it("does not clear active user disk state when called with diskOnly and a different specified user", async () => {
await cryptoService.clearProviderKeys(false, "someOtherUser" as UserId);
expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalledWith(
mockUserId,
USER_ENCRYPTED_PROVIDER_KEYS,
);
});
});
describe("clearKeyPair", () => {
let forceMemoryPrivateKeySpy: jest.Mock;
let forceMemoryPublicKeySpy: jest.Mock;
beforeEach(() => {
forceMemoryPrivateKeySpy = cryptoService["activeUserPrivateKeyState"].forceValue = jest.fn();
forceMemoryPublicKeySpy = cryptoService["activeUserPublicKeyState"].forceValue = jest.fn();
});
it("clears in memory org keys when called with memoryOnly", async () => {
await cryptoService.clearKeyPair(true);
expect(forceMemoryPrivateKeySpy).toHaveBeenCalledWith(null);
expect(forceMemoryPublicKeySpy).toHaveBeenCalledWith(null);
});
it("does not clear memory when called with the non active user and memory only", async () => {
await cryptoService.clearKeyPair(true, "someOtherUser" as UserId);
expect(forceMemoryPrivateKeySpy).not.toHaveBeenCalled();
expect(forceMemoryPublicKeySpy).not.toHaveBeenCalled();
});
it("does not write to disk state if called with memory only", async () => {
await cryptoService.clearKeyPair(true);
expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalled();
});
it("clears disk state when called with diskOnly", async () => {
await cryptoService.clearKeyPair(false);
expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith(
mockUserId,
USER_ENCRYPTED_PRIVATE_KEY,
);
expect(
stateProvider.singleUser.getFake(mockUserId, USER_ENCRYPTED_PRIVATE_KEY).nextMock,
).toHaveBeenCalledWith(null);
});
it("clears another user's disk state when called with diskOnly and that user", async () => {
await cryptoService.clearKeyPair(false, "someOtherUser" as UserId);
expect(stateProvider.singleUser.mock.get).toHaveBeenCalledWith(
"someOtherUser" as UserId,
USER_ENCRYPTED_PRIVATE_KEY,
);
expect(
stateProvider.singleUser.getFake("someOtherUser" as UserId, USER_ENCRYPTED_PRIVATE_KEY)
.nextMock,
).toHaveBeenCalledWith(null);
});
it("does not clear active user disk state when called with diskOnly and a different specified user", async () => {
await cryptoService.clearKeyPair(false, "someOtherUser" as UserId);
expect(stateProvider.singleUser.mock.get).not.toHaveBeenCalledWith(
mockUserId,
USER_ENCRYPTED_PRIVATE_KEY,
);
});
});
describe("clearUserKey", () => {
it("clears the user key for the active user when no userId is specified", async () => {
await cryptoService.clearUserKey(false);
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(USER_KEY, null, undefined);
});
it("clears the user key for the specified user when a userId is specified", async () => {
await cryptoService.clearUserKey(false, "someOtherUser" as UserId);
expect(stateProvider.mock.setUserState).toHaveBeenCalledWith(USER_KEY, null, "someOtherUser");
}); });
it("sets the maximum account status of the active user id to locked when user id is not specified", async () => { it("sets the maximum account status of the active user id to locked when user id is not specified", async () => {
await cryptoService.clearUserKey(false); await cryptoService.clearKeys();
expect(accountService.mock.setMaxAccountStatus).toHaveBeenCalledWith( expect(accountService.mock.setMaxAccountStatus).toHaveBeenCalledWith(
mockUserId, mockUserId,
AuthenticationStatus.Locked, AuthenticationStatus.Locked,
@@ -568,17 +360,36 @@ describe("cryptoService", () => {
}); });
it("sets the maximum account status of the specified user id to locked when user id is specified", async () => { it("sets the maximum account status of the specified user id to locked when user id is specified", async () => {
await cryptoService.clearUserKey(false, "someOtherUser" as UserId); const userId = "someOtherUser" as UserId;
await cryptoService.clearKeys(userId);
expect(accountService.mock.setMaxAccountStatus).toHaveBeenCalledWith( expect(accountService.mock.setMaxAccountStatus).toHaveBeenCalledWith(
"someOtherUser" as UserId, userId,
AuthenticationStatus.Locked, AuthenticationStatus.Locked,
); );
}); });
it("clears all stored user keys when clearAll is true", async () => { describe.each([
const clearAllSpy = (cryptoService["clearAllStoredUserKeys"] = jest.fn()); USER_ENCRYPTED_ORGANIZATION_KEYS,
await cryptoService.clearUserKey(true); USER_ENCRYPTED_PROVIDER_KEYS,
expect(clearAllSpy).toHaveBeenCalledWith(mockUserId); USER_ENCRYPTED_PRIVATE_KEY,
USER_KEY,
])("key removal", (key: UserKeyDefinition<unknown>) => {
it(`clears ${key.key} for active user when unspecified`, async () => {
await cryptoService.clearKeys(null);
const encryptedOrgKeyState = stateProvider.singleUser.getFake(mockUserId, key);
expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledTimes(1);
expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledWith(null);
});
it(`clears ${key.key} for the specified user when specified`, async () => {
const userId = "someOtherUser" as UserId;
await cryptoService.clearKeys(userId);
const encryptedOrgKeyState = stateProvider.singleUser.getFake(userId, key);
expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledTimes(1);
expect(encryptedOrgKeyState.nextMock).toHaveBeenCalledWith(null);
});
}); });
}); });
}); });

View File

@@ -144,7 +144,7 @@ export class CryptoService implements CryptoServiceAbstraction {
async setUserKey(key: UserKey, userId?: UserId): Promise<void> { async setUserKey(key: UserKey, userId?: UserId): Promise<void> {
if (key == null) { if (key == null) {
throw new Error("No key provided. Use ClearUserKey to clear the key"); throw new Error("No key provided. Lock the user to clear the key");
} }
// Set userId to ensure we have one for the account status update // Set userId to ensure we have one for the account status update
[userId, key] = await this.stateProvider.setUserState(USER_KEY, key, userId); [userId, key] = await this.stateProvider.setUserState(USER_KEY, key, userId);
@@ -242,13 +242,19 @@ export class CryptoService implements CryptoServiceAbstraction {
return this.buildProtectedSymmetricKey(masterKey, newUserKey.key); return this.buildProtectedSymmetricKey(masterKey, newUserKey.key);
} }
async clearUserKey(clearStoredKeys = true, userId?: UserId): Promise<void> { /**
// Set userId to ensure we have one for the account status update * Clears the user key. Clears all stored versions of the user keys as well, such as the biometrics key
[userId] = await this.stateProvider.setUserState(USER_KEY, null, userId); * @param userId The desired user
await this.accountService.setMaxAccountStatus(userId, AuthenticationStatus.Locked); */
if (clearStoredKeys) { async clearUserKey(userId: UserId): Promise<void> {
await this.clearAllStoredUserKeys(userId); if (userId == null) {
// nothing to do
return;
} }
// Set userId to ensure we have one for the account status update
await this.stateProvider.setUserState(USER_KEY, null, userId);
await this.accountService.setMaxAccountStatus(userId, AuthenticationStatus.Locked);
await this.clearAllStoredUserKeys(userId);
} }
async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise<void> { async clearStoredUserKey(keySuffix: KeySuffixOptions, userId?: UserId): Promise<void> {
@@ -480,25 +486,12 @@ export class CryptoService implements CryptoServiceAbstraction {
return this.buildProtectedSymmetricKey(key, newSymKey.key); return this.buildProtectedSymmetricKey(key, newSymKey.key);
} }
async clearOrgKeys(memoryOnly?: boolean, userId?: UserId): Promise<void> { private async clearOrgKeys(userId: UserId): Promise<void> {
const activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id; if (userId == null) {
const userIdIsActive = userId == null || userId === activeUserId; // nothing to do
if (!memoryOnly) {
if (userId == null && activeUserId == null) {
// nothing to do
return;
}
await this.stateProvider
.getUser(userId ?? activeUserId, USER_ENCRYPTED_ORGANIZATION_KEYS)
.update(() => null);
return; return;
} }
await this.stateProvider.setUserState(USER_ENCRYPTED_ORGANIZATION_KEYS, null, userId);
// org keys are only cached for active users
if (userIdIsActive) {
await this.activeUserOrgKeysState.forceValue({});
}
} }
async setProviderKeys(providers: ProfileProviderResponse[]): Promise<void> { async setProviderKeys(providers: ProfileProviderResponse[]): Promise<void> {
@@ -526,25 +519,12 @@ export class CryptoService implements CryptoServiceAbstraction {
return await firstValueFrom(this.activeUserProviderKeys$); return await firstValueFrom(this.activeUserProviderKeys$);
} }
async clearProviderKeys(memoryOnly?: boolean, userId?: UserId): Promise<void> { private async clearProviderKeys(userId: UserId): Promise<void> {
const activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id; if (userId == null) {
const userIdIsActive = userId == null || userId === activeUserId; // nothing to do
if (!memoryOnly) {
if (userId == null && activeUserId == null) {
// nothing to do
return;
}
await this.stateProvider
.getUser(userId ?? activeUserId, USER_ENCRYPTED_PROVIDER_KEYS)
.update(() => null);
return; return;
} }
await this.stateProvider.setUserState(USER_ENCRYPTED_PROVIDER_KEYS, null, userId);
// provider keys are only cached for active users
if (userIdIsActive) {
await this.activeUserProviderKeysState.forceValue({});
}
} }
async getPublicKey(): Promise<Uint8Array> { async getPublicKey(): Promise<Uint8Array> {
@@ -597,26 +577,17 @@ export class CryptoService implements CryptoServiceAbstraction {
return [publicB64, privateEnc]; return [publicB64, privateEnc];
} }
async clearKeyPair(memoryOnly?: boolean, userId?: UserId): Promise<void[]> { /**
const activeUserId = (await firstValueFrom(this.accountService.activeAccount$))?.id; * Clears the user's key pair
const userIdIsActive = userId == null || userId === activeUserId; * @param userId The desired user
*/
if (!memoryOnly) { private async clearKeyPair(userId: UserId): Promise<void[]> {
if (userId == null && activeUserId == null) { if (userId == null) {
// nothing to do // nothing to do
return;
}
await this.stateProvider
.getUser(userId ?? activeUserId, USER_ENCRYPTED_PRIVATE_KEY)
.update(() => null);
return; return;
} }
// decrypted key pair is only cached for active users await this.stateProvider.setUserState(USER_ENCRYPTED_PRIVATE_KEY, null, userId);
if (userIdIsActive) {
await this.activeUserPrivateKeyState.forceValue(null);
await this.activeUserPublicKeyState.forceValue(null);
}
} }
async makePinKey(pin: string, salt: string, kdf: KdfType, kdfConfig: KdfConfig): Promise<PinKey> { async makePinKey(pin: string, salt: string, kdf: KdfType, kdfConfig: KdfConfig): Promise<PinKey> {
@@ -681,11 +652,17 @@ export class CryptoService implements CryptoServiceAbstraction {
} }
async clearKeys(userId?: UserId): Promise<any> { async clearKeys(userId?: UserId): Promise<any> {
await this.clearUserKey(true, userId); userId ||= (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (userId == null) {
throw new Error("Cannot clear keys, no user Id resolved.");
}
await this.clearUserKey(userId);
await this.clearMasterKeyHash(userId); await this.clearMasterKeyHash(userId);
await this.clearOrgKeys(false, userId); await this.clearOrgKeys(userId);
await this.clearProviderKeys(false, userId); await this.clearProviderKeys(userId);
await this.clearKeyPair(false, userId); await this.clearKeyPair(userId);
await this.clearPinKeys(userId); await this.clearPinKeys(userId);
await this.stateProvider.setUserState(USER_EVER_HAD_USER_KEY, null, userId); await this.stateProvider.setUserState(USER_EVER_HAD_USER_KEY, null, userId);
} }

View File

@@ -7,9 +7,10 @@ import { UserId } from "../../types/guid";
import { CloudRegion, Region } from "../abstractions/environment.service"; import { CloudRegion, Region } from "../abstractions/environment.service";
import { import {
ENVIRONMENT_KEY, GLOBAL_ENVIRONMENT_KEY,
DefaultEnvironmentService, DefaultEnvironmentService,
EnvironmentUrls, EnvironmentUrls,
USER_ENVIRONMENT_KEY,
} from "./default-environment.service"; } from "./default-environment.service";
// There are a few main states EnvironmentService could be in when first used // There are a few main states EnvironmentService could be in when first used
@@ -55,7 +56,7 @@ describe("EnvironmentService", () => {
}; };
const setGlobalData = (region: Region, environmentUrls: EnvironmentUrls) => { const setGlobalData = (region: Region, environmentUrls: EnvironmentUrls) => {
stateProvider.global.getFake(ENVIRONMENT_KEY).stateSubject.next({ stateProvider.global.getFake(GLOBAL_ENVIRONMENT_KEY).stateSubject.next({
region: region, region: region,
urls: environmentUrls, urls: environmentUrls,
}); });
@@ -66,7 +67,7 @@ describe("EnvironmentService", () => {
environmentUrls: EnvironmentUrls, environmentUrls: EnvironmentUrls,
userId: UserId = testUser, userId: UserId = testUser,
) => { ) => {
stateProvider.singleUser.getFake(userId, ENVIRONMENT_KEY).nextState({ stateProvider.singleUser.getFake(userId, USER_ENVIRONMENT_KEY).nextState({
region: region, region: region,
urls: environmentUrls, urls: environmentUrls,
}); });

View File

@@ -18,6 +18,7 @@ import {
GlobalState, GlobalState,
KeyDefinition, KeyDefinition,
StateProvider, StateProvider,
UserKeyDefinition,
} from "../state"; } from "../state";
export class EnvironmentUrls { export class EnvironmentUrls {
@@ -40,7 +41,7 @@ class EnvironmentState {
} }
} }
export const ENVIRONMENT_KEY = new KeyDefinition<EnvironmentState>( export const GLOBAL_ENVIRONMENT_KEY = new KeyDefinition<EnvironmentState>(
ENVIRONMENT_DISK, ENVIRONMENT_DISK,
"environment", "environment",
{ {
@@ -48,9 +49,31 @@ export const ENVIRONMENT_KEY = new KeyDefinition<EnvironmentState>(
}, },
); );
export const CLOUD_REGION_KEY = new KeyDefinition<CloudRegion>(ENVIRONMENT_MEMORY, "cloudRegion", { export const USER_ENVIRONMENT_KEY = new UserKeyDefinition<EnvironmentState>(
deserializer: (b) => b, ENVIRONMENT_DISK,
}); "environment",
{
deserializer: EnvironmentState.fromJSON,
clearOn: ["logout"],
},
);
export const GLOBAL_CLOUD_REGION_KEY = new KeyDefinition<CloudRegion>(
ENVIRONMENT_MEMORY,
"cloudRegion",
{
deserializer: (b) => b,
},
);
export const USER_CLOUD_REGION_KEY = new UserKeyDefinition<CloudRegion>(
ENVIRONMENT_MEMORY,
"cloudRegion",
{
deserializer: (b) => b,
clearOn: ["logout"],
},
);
/** /**
* The production regions available for selection. * The production regions available for selection.
@@ -114,8 +137,8 @@ export class DefaultEnvironmentService implements EnvironmentService {
private stateProvider: StateProvider, private stateProvider: StateProvider,
private accountService: AccountService, private accountService: AccountService,
) { ) {
this.globalState = this.stateProvider.getGlobal(ENVIRONMENT_KEY); this.globalState = this.stateProvider.getGlobal(GLOBAL_ENVIRONMENT_KEY);
this.globalCloudRegionState = this.stateProvider.getGlobal(CLOUD_REGION_KEY); this.globalCloudRegionState = this.stateProvider.getGlobal(GLOBAL_CLOUD_REGION_KEY);
const account$ = this.activeAccountId$.pipe( const account$ = this.activeAccountId$.pipe(
// Use == here to not trigger on undefined -> null transition // Use == here to not trigger on undefined -> null transition
@@ -125,8 +148,8 @@ export class DefaultEnvironmentService implements EnvironmentService {
this.environment$ = account$.pipe( this.environment$ = account$.pipe(
switchMap((userId) => { switchMap((userId) => {
const t = userId const t = userId
? this.stateProvider.getUser(userId, ENVIRONMENT_KEY).state$ ? this.stateProvider.getUser(userId, USER_ENVIRONMENT_KEY).state$
: this.stateProvider.getGlobal(ENVIRONMENT_KEY).state$; : this.stateProvider.getGlobal(GLOBAL_ENVIRONMENT_KEY).state$;
return t; return t;
}), }),
map((state) => { map((state) => {
@@ -136,8 +159,8 @@ export class DefaultEnvironmentService implements EnvironmentService {
this.cloudWebVaultUrl$ = account$.pipe( this.cloudWebVaultUrl$ = account$.pipe(
switchMap((userId) => { switchMap((userId) => {
const t = userId const t = userId
? this.stateProvider.getUser(userId, CLOUD_REGION_KEY).state$ ? this.stateProvider.getUser(userId, USER_CLOUD_REGION_KEY).state$
: this.stateProvider.getGlobal(CLOUD_REGION_KEY).state$; : this.stateProvider.getGlobal(GLOBAL_CLOUD_REGION_KEY).state$;
return t; return t;
}), }),
map((region) => { map((region) => {
@@ -242,7 +265,7 @@ export class DefaultEnvironmentService implements EnvironmentService {
if (userId == null) { if (userId == null) {
await this.globalCloudRegionState.update(() => region); await this.globalCloudRegionState.update(() => region);
} else { } else {
await this.stateProvider.getUser(userId, CLOUD_REGION_KEY).update(() => region); await this.stateProvider.getUser(userId, USER_CLOUD_REGION_KEY).update(() => region);
} }
} }
@@ -261,13 +284,13 @@ export class DefaultEnvironmentService implements EnvironmentService {
return activeUserId == null return activeUserId == null
? await firstValueFrom(this.globalState.state$) ? await firstValueFrom(this.globalState.state$)
: await firstValueFrom( : await firstValueFrom(
this.stateProvider.getUser(userId ?? activeUserId, ENVIRONMENT_KEY).state$, this.stateProvider.getUser(userId ?? activeUserId, USER_ENVIRONMENT_KEY).state$,
); );
} }
async seedUserEnvironment(userId: UserId) { async seedUserEnvironment(userId: UserId) {
const global = await firstValueFrom(this.globalState.state$); const global = await firstValueFrom(this.globalState.state$);
await this.stateProvider.getUser(userId, ENVIRONMENT_KEY).update(() => global); await this.stateProvider.getUser(userId, USER_ENVIRONMENT_KEY).update(() => global);
} }
} }

View File

@@ -4,13 +4,14 @@ import { OrganizationId } from "../../../types/guid";
import { OrgKey } from "../../../types/key"; import { OrgKey } from "../../../types/key";
import { CryptoService } from "../../abstractions/crypto.service"; import { CryptoService } from "../../abstractions/crypto.service";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { KeyDefinition, CRYPTO_DISK, DeriveDefinition } from "../../state"; import { CRYPTO_DISK, DeriveDefinition, UserKeyDefinition } from "../../state";
export const USER_ENCRYPTED_ORGANIZATION_KEYS = KeyDefinition.record< export const USER_ENCRYPTED_ORGANIZATION_KEYS = UserKeyDefinition.record<
EncryptedOrganizationKeyData, EncryptedOrganizationKeyData,
OrganizationId OrganizationId
>(CRYPTO_DISK, "organizationKeys", { >(CRYPTO_DISK, "organizationKeys", {
deserializer: (obj) => obj, deserializer: (obj) => obj,
clearOn: ["logout"],
}); });
export const USER_ORGANIZATION_KEYS = DeriveDefinition.from< export const USER_ORGANIZATION_KEYS = DeriveDefinition.from<

View File

@@ -3,14 +3,15 @@ import { ProviderKey } from "../../../types/key";
import { EncryptService } from "../../abstractions/encrypt.service"; import { EncryptService } from "../../abstractions/encrypt.service";
import { EncString, EncryptedString } from "../../models/domain/enc-string"; import { EncString, EncryptedString } from "../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { KeyDefinition, CRYPTO_DISK, DeriveDefinition } from "../../state"; import { CRYPTO_DISK, DeriveDefinition, UserKeyDefinition } from "../../state";
import { CryptoService } from "../crypto.service"; import { CryptoService } from "../crypto.service";
export const USER_ENCRYPTED_PROVIDER_KEYS = KeyDefinition.record<EncryptedString, ProviderId>( export const USER_ENCRYPTED_PROVIDER_KEYS = UserKeyDefinition.record<EncryptedString, ProviderId>(
CRYPTO_DISK, CRYPTO_DISK,
"providerKeys", "providerKeys",
{ {
deserializer: (obj) => obj, deserializer: (obj) => obj,
clearOn: ["logout"],
}, },
); );

View File

@@ -3,18 +3,25 @@ import { CryptoFunctionService } from "../../abstractions/crypto-function.servic
import { EncryptService } from "../../abstractions/encrypt.service"; import { EncryptService } from "../../abstractions/encrypt.service";
import { EncString, EncryptedString } from "../../models/domain/enc-string"; import { EncString, EncryptedString } from "../../models/domain/enc-string";
import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key"; import { SymmetricCryptoKey } from "../../models/domain/symmetric-crypto-key";
import { KeyDefinition, CRYPTO_DISK, DeriveDefinition, CRYPTO_MEMORY } from "../../state"; import {
KeyDefinition,
CRYPTO_DISK,
DeriveDefinition,
CRYPTO_MEMORY,
UserKeyDefinition,
} from "../../state";
import { CryptoService } from "../crypto.service"; import { CryptoService } from "../crypto.service";
export const USER_EVER_HAD_USER_KEY = new KeyDefinition<boolean>(CRYPTO_DISK, "everHadUserKey", { export const USER_EVER_HAD_USER_KEY = new KeyDefinition<boolean>(CRYPTO_DISK, "everHadUserKey", {
deserializer: (obj) => obj, deserializer: (obj) => obj,
}); });
export const USER_ENCRYPTED_PRIVATE_KEY = new KeyDefinition<EncryptedString>( export const USER_ENCRYPTED_PRIVATE_KEY = new UserKeyDefinition<EncryptedString>(
CRYPTO_DISK, CRYPTO_DISK,
"privateKey", "privateKey",
{ {
deserializer: (obj) => obj, deserializer: (obj) => obj,
clearOn: ["logout"],
}, },
); );
@@ -58,6 +65,7 @@ export const USER_PUBLIC_KEY = DeriveDefinition.from<
return (await cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey; return (await cryptoFunctionService.rsaExtractPublicKey(privateKey)) as UserPublicKey;
}, },
}); });
export const USER_KEY = new KeyDefinition<UserKey>(CRYPTO_MEMORY, "userKey", { export const USER_KEY = new UserKeyDefinition<UserKey>(CRYPTO_MEMORY, "userKey", {
deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey, deserializer: (obj) => SymmetricCryptoKey.fromJSON(obj) as UserKey,
clearOn: ["logout", "lock"],
}); });

View File

@@ -5,6 +5,7 @@ import { DerivedStateDependencies, StorageKey } from "../../types/state";
import { KeyDefinition } from "./key-definition"; import { KeyDefinition } from "./key-definition";
import { StateDefinition } from "./state-definition"; import { StateDefinition } from "./state-definition";
import { UserKeyDefinition } from "./user-key-definition";
declare const depShapeMarker: unique symbol; declare const depShapeMarker: unique symbol;
/** /**
@@ -129,26 +130,28 @@ export class DeriveDefinition<TFrom, TTo, TDeps extends DerivedStateDependencies
static from<TFrom, TTo, TDeps extends DerivedStateDependencies = never>( static from<TFrom, TTo, TDeps extends DerivedStateDependencies = never>(
definition: definition:
| KeyDefinition<TFrom> | KeyDefinition<TFrom>
| UserKeyDefinition<TFrom>
| [DeriveDefinition<unknown, TFrom, DerivedStateDependencies>, string], | [DeriveDefinition<unknown, TFrom, DerivedStateDependencies>, string],
options: DeriveDefinitionOptions<TFrom, TTo, TDeps>, options: DeriveDefinitionOptions<TFrom, TTo, TDeps>,
) { ) {
if (isKeyDefinition(definition)) { if (isFromDeriveDefinition(definition)) {
return new DeriveDefinition(definition.stateDefinition, definition.key, options);
} else {
return new DeriveDefinition(definition[0].stateDefinition, definition[1], options); return new DeriveDefinition(definition[0].stateDefinition, definition[1], options);
} else {
return new DeriveDefinition(definition.stateDefinition, definition.key, options);
} }
} }
static fromWithUserId<TKeyDef, TTo, TDeps extends DerivedStateDependencies = never>( static fromWithUserId<TKeyDef, TTo, TDeps extends DerivedStateDependencies = never>(
definition: definition:
| KeyDefinition<TKeyDef> | KeyDefinition<TKeyDef>
| UserKeyDefinition<TKeyDef>
| [DeriveDefinition<unknown, TKeyDef, DerivedStateDependencies>, string], | [DeriveDefinition<unknown, TKeyDef, DerivedStateDependencies>, string],
options: DeriveDefinitionOptions<[UserId, TKeyDef], TTo, TDeps>, options: DeriveDefinitionOptions<[UserId, TKeyDef], TTo, TDeps>,
) { ) {
if (isKeyDefinition(definition)) { if (isFromDeriveDefinition(definition)) {
return new DeriveDefinition(definition.stateDefinition, definition.key, options);
} else {
return new DeriveDefinition(definition[0].stateDefinition, definition[1], options); return new DeriveDefinition(definition[0].stateDefinition, definition[1], options);
} else {
return new DeriveDefinition(definition.stateDefinition, definition.key, options);
} }
} }
@@ -181,10 +184,11 @@ export class DeriveDefinition<TFrom, TTo, TDeps extends DerivedStateDependencies
} }
} }
function isKeyDefinition( function isFromDeriveDefinition(
definition: definition:
| KeyDefinition<unknown> | KeyDefinition<unknown>
| UserKeyDefinition<unknown>
| [DeriveDefinition<unknown, unknown, DerivedStateDependencies>, string], | [DeriveDefinition<unknown, unknown, DerivedStateDependencies>, string],
): definition is KeyDefinition<unknown> { ): definition is [DeriveDefinition<unknown, unknown, DerivedStateDependencies>, string] {
return Object.prototype.hasOwnProperty.call(definition, "key"); return Array.isArray(definition);
} }

View File

@@ -156,7 +156,6 @@ describe("VaultTimeoutService", () => {
expect(vaultTimeoutSettingsService.availableVaultTimeoutActions$).toHaveBeenCalledWith(userId); expect(vaultTimeoutSettingsService.availableVaultTimeoutActions$).toHaveBeenCalledWith(userId);
expect(stateService.setEverBeenUnlocked).toHaveBeenCalledWith(true, { userId: userId }); expect(stateService.setEverBeenUnlocked).toHaveBeenCalledWith(true, { userId: userId });
expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, { userId: userId }); expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, { userId: userId });
expect(cryptoService.clearUserKey).toHaveBeenCalledWith(false, userId);
expect(cryptoService.clearMasterKey).toHaveBeenCalledWith(userId); expect(cryptoService.clearMasterKey).toHaveBeenCalledWith(userId);
expect(cipherService.clearCache).toHaveBeenCalledWith(userId); expect(cipherService.clearCache).toHaveBeenCalledWith(userId);
expect(lockedCallback).toHaveBeenCalledWith(userId); expect(lockedCallback).toHaveBeenCalledWith(userId);

View File

@@ -96,10 +96,7 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
await this.stateService.setUserKeyAutoUnlock(null, { userId: userId }); await this.stateService.setUserKeyAutoUnlock(null, { userId: userId });
await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId }); await this.stateService.setCryptoMasterKeyAuto(null, { userId: userId });
await this.cryptoService.clearUserKey(false, userId);
await this.cryptoService.clearMasterKey(userId); await this.cryptoService.clearMasterKey(userId);
await this.cryptoService.clearOrgKeys(true, userId);
await this.cryptoService.clearKeyPair(true, userId);
await this.cipherService.clearCache(userId); await this.cipherService.clearCache(userId);