1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +00:00

[PM-5363] PinService State Providers (#8244)

* move pinKeyEncryptedUserKey

* move pinKeyEncryptedUserKeyEphemeral

* remove comments, move docs

* cleanup

* use UserKeyDefinition

* refactor methods

* add migration

* fix browser dependency

* add tests for migration

* rename to pinService

* move state to PinService

* add PinService dep to CryptoService

* move protectedPin to state provider

* update service deps

* renaming

* move decryptUserKeyWithPin to pinService

* update service injection

* move more methods our of crypto service

* remove CryptoService dep from PinService and update service injection

* remove cryptoService reference

* add method to FakeMasterPasswordService

* fix circular dependency

* fix desktop service injection

* update browser dependencies

* add protectedPin to migrations

* move storePinKey to pinService

* update and clarify documentation

* more jsdoc updates

* update import paths

* refactor isPinLockSet method

* update state definitions

* initialize service before injecting into other services

* initialize service before injecting into other services (bw.ts)

* update clearOn and do additional cleanup

* clarify docs and naming

* assign abstract & private methods, add clarity to decryptAndMigrateOldPinKeyEncryptedMasterKey() method

* derived state (attempt)

* fix typos

* use accountService to get active user email

* use constant userId

* add derived state

* add get and clear for oldPinKeyEncryptedMasterKey

* require userId

* move pinProtected

* add clear methods

* remove pinProtected from account.ts and replace methods

* add methods to create and store pinKeyEncryptedUserKey

* add pinProtected/oldPinKeyEncrypterMasterKey to migration

* update migration tests

* update migration rollback tests

* update to systemService and decryptAndMigrate... method

* remove old test

* increase length of state definition name to meet test requirements

* rename 'TRANSIENT' to 'EPHEMERAL' for consistency

* fix tests for login strategies, vault-export, and fake MP service

* more updates to login-strategy tests

* write new tests for core pinKeyEncrypterUserKey methods and isPinSet

* write new tests for pinProtected and oldPinKeyEncryptedMasterKey methods

* minor test reformatting

* update test for decryptUserKeyWithPin()

* fix bug with oldPinKeyEncryptedMasterKey

* fix tests for vault-timeout-settings.service

* fix bitwarden-password-protected-importer test

* fix login strategy tests and auth-request.service test

* update pinService tests

* fix crypto service tests

* add jsdoc

* fix test file import

* update jsdocs for decryptAndMigrateOldPinKeyEncryptedMasterKey()

* update error messages and jsdocs

* add null checks, move userId retrievals

* update migration tests

* update stateService calls to require userId

* update test for decryptUserKeyWithPin()

* update oldPinKeyEncryptedMasterKey migration tests

* more test updates

* fix factory import

* update tests for isPinSet() and createProtectedPin()

* add test for makePinKey()

* add test for createPinKeyEncryptedUserKey()

* add tests for getPinLockType()

* consolidate userId verification tests

* add tests for storePinKeyEncryptedUserKey()

* fix service dep

* get email based on userId

* use MasterPasswordService instead of internal

* rename protectedPin to userKeyEncryptedPin

* rename to pinKeyEncryptedUserKeyPersistent

* update method params

* fix CryptoService tests

* jsdoc update

* use EncString for userKeyEncryptedPin

* remove comment

* use cryptoFunctionService.compareFast()

* update tests

* cleanup, remove comments

* resolve merge conflict

* fix DI of MasterPasswordService

* more DI fixes
This commit is contained in:
rr-bw
2024-05-08 11:34:47 -07:00
committed by GitHub
parent c2812fc21d
commit a42de41587
84 changed files with 2182 additions and 998 deletions

View File

@@ -0,0 +1,501 @@
import { firstValueFrom, map } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KdfConfigService } from "@bitwarden/common/auth/abstractions/kdf-config.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/auth/abstractions/master-password.service.abstraction";
import { KdfConfig } from "@bitwarden/common/auth/models/domain/kdf-config";
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/key-generation.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { EncString, EncryptedString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import {
PIN_DISK,
PIN_MEMORY,
StateProvider,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { MasterKey, PinKey, UserKey } from "@bitwarden/common/types/key";
import { PinServiceAbstraction } from "../../abstractions/pin.service.abstraction";
/**
* - DISABLED : No PIN set.
* - PERSISTENT : PIN is set and persists through client reset.
* - EPHEMERAL : PIN is set, but does NOT persist through client reset. This means that
* after client reset the master password is required to unlock.
*/
export type PinLockType = "DISABLED" | "PERSISTENT" | "EPHEMERAL";
/**
* The persistent (stored on disk) version of the UserKey, encrypted by the PinKey.
*
* @remarks Persists through a client reset. Used when `requireMasterPasswordOnClientRestart` is disabled.
* @see SetPinComponent.setPinForm.requireMasterPasswordOnClientRestart
*/
export const PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT = new UserKeyDefinition<EncryptedString>(
PIN_DISK,
"pinKeyEncryptedUserKeyPersistent",
{
deserializer: (jsonValue) => jsonValue,
clearOn: ["logout"],
},
);
/**
* The ephemeral (stored in memory) version of the UserKey, encrypted by the PinKey.
*
* @remarks Does NOT persist through a client reset. Used when `requireMasterPasswordOnClientRestart` is enabled.
* @see SetPinComponent.setPinForm.requireMasterPasswordOnClientRestart
*/
export const PIN_KEY_ENCRYPTED_USER_KEY_EPHEMERAL = new UserKeyDefinition<EncryptedString>(
PIN_MEMORY,
"pinKeyEncryptedUserKeyEphemeral",
{
deserializer: (jsonValue) => jsonValue,
clearOn: ["logout"],
},
);
/**
* The PIN, encrypted by the UserKey.
*/
export const USER_KEY_ENCRYPTED_PIN = new UserKeyDefinition<EncryptedString>(
PIN_DISK,
"userKeyEncryptedPin",
{
deserializer: (jsonValue) => jsonValue,
clearOn: ["logout"],
},
);
/**
* The old MasterKey, encrypted by the PinKey (formerly called `pinProtected`).
* Deprecated and used for migration purposes only.
*/
export const OLD_PIN_KEY_ENCRYPTED_MASTER_KEY = new UserKeyDefinition<EncryptedString>(
PIN_DISK,
"oldPinKeyEncryptedMasterKey",
{
deserializer: (jsonValue) => jsonValue,
clearOn: ["logout"],
},
);
export class PinService implements PinServiceAbstraction {
constructor(
private accountService: AccountService,
private cryptoFunctionService: CryptoFunctionService,
private encryptService: EncryptService,
private kdfConfigService: KdfConfigService,
private keyGenerationService: KeyGenerationService,
private logService: LogService,
private masterPasswordService: MasterPasswordServiceAbstraction,
private stateProvider: StateProvider,
private stateService: StateService,
) {}
async getPinKeyEncryptedUserKeyPersistent(userId: UserId): Promise<EncString> {
this.validateUserId(userId, "Cannot get pinKeyEncryptedUserKeyPersistent.");
return EncString.fromJSON(
await firstValueFrom(
this.stateProvider.getUserState$(PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, userId),
),
);
}
/**
* Sets the persistent (stored on disk) version of the UserKey, encrypted by the PinKey.
*/
private async setPinKeyEncryptedUserKeyPersistent(
pinKeyEncryptedUserKey: EncString,
userId: UserId,
): Promise<void> {
this.validateUserId(userId, "Cannot set pinKeyEncryptedUserKeyPersistent.");
if (pinKeyEncryptedUserKey == null) {
throw new Error(
"No pinKeyEncryptedUserKey provided. Cannot set pinKeyEncryptedUserKeyPersistent.",
);
}
await this.stateProvider.setUserState(
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
pinKeyEncryptedUserKey?.encryptedString,
userId,
);
}
async clearPinKeyEncryptedUserKeyPersistent(userId: UserId): Promise<void> {
this.validateUserId(userId, "Cannot clear pinKeyEncryptedUserKeyPersistent.");
await this.stateProvider.setUserState(PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, null, userId);
}
async getPinKeyEncryptedUserKeyEphemeral(userId: UserId): Promise<EncString> {
this.validateUserId(userId, "Cannot get pinKeyEncryptedUserKeyEphemeral.");
return EncString.fromJSON(
await firstValueFrom(
this.stateProvider.getUserState$(PIN_KEY_ENCRYPTED_USER_KEY_EPHEMERAL, userId),
),
);
}
/**
* Sets the ephemeral (stored in memory) version of the UserKey, encrypted by the PinKey.
*/
private async setPinKeyEncryptedUserKeyEphemeral(
pinKeyEncryptedUserKey: EncString,
userId: UserId,
): Promise<void> {
this.validateUserId(userId, "Cannot set pinKeyEncryptedUserKeyEphemeral.");
if (pinKeyEncryptedUserKey == null) {
throw new Error(
"No pinKeyEncryptedUserKey provided. Cannot set pinKeyEncryptedUserKeyEphemeral.",
);
}
await this.stateProvider.setUserState(
PIN_KEY_ENCRYPTED_USER_KEY_EPHEMERAL,
pinKeyEncryptedUserKey?.encryptedString,
userId,
);
}
async clearPinKeyEncryptedUserKeyEphemeral(userId: UserId): Promise<void> {
this.validateUserId(userId, "Cannot clear pinKeyEncryptedUserKeyEphemeral.");
await this.stateProvider.setUserState(PIN_KEY_ENCRYPTED_USER_KEY_EPHEMERAL, null, userId);
}
async createPinKeyEncryptedUserKey(
pin: string,
userKey: UserKey,
userId: UserId,
): Promise<EncString> {
this.validateUserId(userId, "Cannot create pinKeyEncryptedUserKey.");
if (!userKey) {
throw new Error("No UserKey provided. Cannot create pinKeyEncryptedUserKey.");
}
const email = await firstValueFrom(
this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)),
);
const kdfConfig = await this.kdfConfigService.getKdfConfig();
const pinKey = await this.makePinKey(pin, email, kdfConfig);
return await this.encryptService.encrypt(userKey.key, pinKey);
}
async storePinKeyEncryptedUserKey(
pinKeyEncryptedUserKey: EncString,
storeAsEphemeral: boolean,
userId: UserId,
): Promise<void> {
this.validateUserId(userId, "Cannot store pinKeyEncryptedUserKey.");
if (storeAsEphemeral) {
await this.setPinKeyEncryptedUserKeyEphemeral(pinKeyEncryptedUserKey, userId);
} else {
await this.setPinKeyEncryptedUserKeyPersistent(pinKeyEncryptedUserKey, userId);
}
}
async getUserKeyEncryptedPin(userId: UserId): Promise<EncString> {
this.validateUserId(userId, "Cannot get userKeyEncryptedPin.");
return EncString.fromJSON(
await firstValueFrom(this.stateProvider.getUserState$(USER_KEY_ENCRYPTED_PIN, userId)),
);
}
async setUserKeyEncryptedPin(userKeyEncryptedPin: EncString, userId: UserId): Promise<void> {
this.validateUserId(userId, "Cannot set userKeyEncryptedPin.");
await this.stateProvider.setUserState(
USER_KEY_ENCRYPTED_PIN,
userKeyEncryptedPin?.encryptedString,
userId,
);
}
async clearUserKeyEncryptedPin(userId: UserId): Promise<void> {
this.validateUserId(userId, "Cannot clear userKeyEncryptedPin.");
await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, null, userId);
}
async createUserKeyEncryptedPin(pin: string, userKey: UserKey): Promise<EncString> {
if (!userKey) {
throw new Error("No UserKey provided. Cannot create userKeyEncryptedPin.");
}
return await this.encryptService.encrypt(pin, userKey);
}
async getOldPinKeyEncryptedMasterKey(userId: UserId): Promise<EncryptedString> {
this.validateUserId(userId, "Cannot get oldPinKeyEncryptedMasterKey.");
return await firstValueFrom(
this.stateProvider.getUserState$(OLD_PIN_KEY_ENCRYPTED_MASTER_KEY, userId),
);
}
async clearOldPinKeyEncryptedMasterKey(userId: UserId): Promise<void> {
this.validateUserId(userId, "Cannot clear oldPinKeyEncryptedMasterKey.");
await this.stateProvider.setUserState(OLD_PIN_KEY_ENCRYPTED_MASTER_KEY, null, userId);
}
async makePinKey(pin: string, salt: string, kdfConfig: KdfConfig): Promise<PinKey> {
const pinKey = await this.keyGenerationService.deriveKeyFromPassword(pin, salt, kdfConfig);
return (await this.keyGenerationService.stretchKey(pinKey)) as PinKey;
}
async getPinLockType(userId: UserId): Promise<PinLockType> {
this.validateUserId(userId, "Cannot get PinLockType.");
/**
* We can't check the `userKeyEncryptedPin` (formerly called `protectedPin`) for both because old
* accounts only used it for MP on Restart
*/
const aUserKeyEncryptedPinIsSet = !!(await this.getUserKeyEncryptedPin(userId));
const aPinKeyEncryptedUserKeyPersistentIsSet =
!!(await this.getPinKeyEncryptedUserKeyPersistent(userId));
const anOldPinKeyEncryptedMasterKeyIsSet =
!!(await this.getOldPinKeyEncryptedMasterKey(userId));
if (aPinKeyEncryptedUserKeyPersistentIsSet || anOldPinKeyEncryptedMasterKeyIsSet) {
return "PERSISTENT";
} else if (
aUserKeyEncryptedPinIsSet &&
!aPinKeyEncryptedUserKeyPersistentIsSet &&
!anOldPinKeyEncryptedMasterKeyIsSet
) {
return "EPHEMERAL";
} else {
return "DISABLED";
}
}
async isPinSet(userId: UserId): Promise<boolean> {
this.validateUserId(userId, "Cannot determine if PIN is set.");
return (await this.getPinLockType(userId)) !== "DISABLED";
}
async decryptUserKeyWithPin(pin: string, userId: UserId): Promise<UserKey | null> {
this.validateUserId(userId, "Cannot decrypt user key with PIN.");
try {
const pinLockType = await this.getPinLockType(userId);
const requireMasterPasswordOnClientRestart = pinLockType === "EPHEMERAL";
const { pinKeyEncryptedUserKey, oldPinKeyEncryptedMasterKey } =
await this.getPinKeyEncryptedKeys(pinLockType, userId);
const email = await firstValueFrom(
this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)),
);
const kdfConfig = await this.kdfConfigService.getKdfConfig();
let userKey: UserKey;
if (oldPinKeyEncryptedMasterKey) {
userKey = await this.decryptAndMigrateOldPinKeyEncryptedMasterKey(
userId,
pin,
email,
kdfConfig,
requireMasterPasswordOnClientRestart,
oldPinKeyEncryptedMasterKey,
);
} else {
userKey = await this.decryptUserKey(userId, pin, email, kdfConfig, pinKeyEncryptedUserKey);
}
if (!userKey) {
this.logService.warning(`User key null after pin key decryption.`);
return null;
}
if (!(await this.validatePin(userKey, pin, userId))) {
this.logService.warning(`Pin key decryption successful but pin validation failed.`);
return null;
}
return userKey;
} catch (error) {
this.logService.error(`Error decrypting user key with pin: ${error}`);
return null;
}
}
/**
* Decrypts the UserKey with the provided PIN.
*/
private async decryptUserKey(
userId: UserId,
pin: string,
salt: string,
kdfConfig: KdfConfig,
pinKeyEncryptedUserKey?: EncString,
): Promise<UserKey> {
this.validateUserId(userId, "Cannot decrypt user key.");
pinKeyEncryptedUserKey ||= await this.getPinKeyEncryptedUserKeyPersistent(userId);
pinKeyEncryptedUserKey ||= await this.getPinKeyEncryptedUserKeyEphemeral(userId);
if (!pinKeyEncryptedUserKey) {
throw new Error("No pinKeyEncryptedUserKey found.");
}
const pinKey = await this.makePinKey(pin, salt, kdfConfig);
const userKey = await this.encryptService.decryptToBytes(pinKeyEncryptedUserKey, pinKey);
return new SymmetricCryptoKey(userKey) as UserKey;
}
/**
* Creates a new `pinKeyEncryptedUserKey` and clears the `oldPinKeyEncryptedMasterKey`.
* @returns UserKey
*/
private async decryptAndMigrateOldPinKeyEncryptedMasterKey(
userId: UserId,
pin: string,
email: string,
kdfConfig: KdfConfig,
requireMasterPasswordOnClientRestart: boolean,
oldPinKeyEncryptedMasterKey: EncString,
): Promise<UserKey> {
this.validateUserId(userId, "Cannot decrypt and migrate oldPinKeyEncryptedMasterKey.");
const masterKey = await this.decryptMasterKeyWithPin(
userId,
pin,
email,
kdfConfig,
oldPinKeyEncryptedMasterKey,
);
const encUserKey = await this.stateService.getEncryptedCryptoSymmetricKey({ userId: userId });
const userKey = await this.masterPasswordService.decryptUserKeyWithMasterKey(
masterKey,
new EncString(encUserKey),
);
const pinKeyEncryptedUserKey = await this.createPinKeyEncryptedUserKey(pin, userKey, userId);
await this.storePinKeyEncryptedUserKey(
pinKeyEncryptedUserKey,
requireMasterPasswordOnClientRestart,
userId,
);
const userKeyEncryptedPin = await this.createUserKeyEncryptedPin(pin, userKey);
await this.setUserKeyEncryptedPin(userKeyEncryptedPin, userId);
await this.clearOldPinKeyEncryptedMasterKey(userId);
// This also clears the old Biometrics key since the new Biometrics key will be created when the user key is set.
await this.stateService.setCryptoMasterKeyBiometric(null, { userId: userId });
return userKey;
}
// Only for migration purposes
private async decryptMasterKeyWithPin(
userId: UserId,
pin: string,
salt: string,
kdfConfig: KdfConfig,
oldPinKeyEncryptedMasterKey?: EncString,
): Promise<MasterKey> {
this.validateUserId(userId, "Cannot decrypt master key with PIN.");
if (!oldPinKeyEncryptedMasterKey) {
const oldPinKeyEncryptedMasterKeyString = await this.getOldPinKeyEncryptedMasterKey(userId);
if (oldPinKeyEncryptedMasterKeyString == null) {
throw new Error("No oldPinKeyEncrytedMasterKey found.");
}
oldPinKeyEncryptedMasterKey = new EncString(oldPinKeyEncryptedMasterKeyString);
}
const pinKey = await this.makePinKey(pin, salt, kdfConfig);
const masterKey = await this.encryptService.decryptToBytes(oldPinKeyEncryptedMasterKey, pinKey);
return new SymmetricCryptoKey(masterKey) as MasterKey;
}
/**
* Gets the user's `pinKeyEncryptedUserKey` (persistent or ephemeral) and `oldPinKeyEncryptedMasterKey`
* (if one exists) based on the user's PinLockType.
*
* @remarks The `oldPinKeyEncryptedMasterKey` (formerly `pinProtected`) is only used for migration and
* will be null for all migrated accounts.
* @throws If PinLockType is 'DISABLED' or if userId is not provided
*/
private async getPinKeyEncryptedKeys(
pinLockType: PinLockType,
userId: UserId,
): Promise<{ pinKeyEncryptedUserKey: EncString; oldPinKeyEncryptedMasterKey?: EncString }> {
this.validateUserId(userId, "Cannot get PinKey encrypted keys.");
switch (pinLockType) {
case "PERSISTENT": {
const pinKeyEncryptedUserKey = await this.getPinKeyEncryptedUserKeyPersistent(userId);
const oldPinKeyEncryptedMasterKey = await this.getOldPinKeyEncryptedMasterKey(userId);
return {
pinKeyEncryptedUserKey,
oldPinKeyEncryptedMasterKey: oldPinKeyEncryptedMasterKey
? new EncString(oldPinKeyEncryptedMasterKey)
: undefined,
};
}
case "EPHEMERAL": {
const pinKeyEncryptedUserKey = await this.getPinKeyEncryptedUserKeyEphemeral(userId);
return {
pinKeyEncryptedUserKey,
oldPinKeyEncryptedMasterKey: undefined, // Going forward, we only migrate non-ephemeral version
};
}
case "DISABLED":
throw new Error("Pin is disabled");
default: {
// Compile-time check for exhaustive switch
const _exhaustiveCheck: never = pinLockType;
return _exhaustiveCheck;
}
}
}
private async validatePin(userKey: UserKey, pin: string, userId: UserId): Promise<boolean> {
this.validateUserId(userId, "Cannot validate PIN.");
const userKeyEncryptedPin = await this.getUserKeyEncryptedPin(userId);
const decryptedPin = await this.encryptService.decryptToUtf8(userKeyEncryptedPin, userKey);
const isPinValid = this.cryptoFunctionService.compareFast(decryptedPin, pin);
return isPinValid;
}
/**
* Throws a custom error message if user ID is not provided.
*/
private validateUserId(userId: UserId, errorMessage: string = "") {
if (!userId) {
throw new Error(`User ID is required. ${errorMessage}`);
}
}
}