mirror of
https://github.com/bitwarden/browser
synced 2026-02-28 18:43:26 +00:00
[PM-31763] Add unlock service & module (#18870)
* Add unlock service * Move methods * Prettier * Fix type errors * Prettier * Fix test * Fix module order * Attempt to fix tests * Cleanup CODEOWNERS * Backport biometric unlock and legacy master-key logic * Add tests for biometrics * Prettier * Add biometric unlock to abstract unlock service * Fix build
This commit is contained in:
219
libs/unlock/src/default-unlock.service.spec.ts
Normal file
219
libs/unlock/src/default-unlock.service.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
// Polyfill for Symbol.dispose required by the service's use of `using` keyword
|
||||
import "core-js/proposals/explicit-resource-management";
|
||||
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { PinStateServiceAbstraction } from "@bitwarden/common/key-management/pin/pin-state.service.abstraction";
|
||||
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsService, KdfConfigService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { PureCrypto } from "@bitwarden/sdk-internal";
|
||||
import { StateProvider } from "@bitwarden/state";
|
||||
|
||||
import { DefaultUnlockService } from "./default-unlock.service";
|
||||
|
||||
const mockUserId = "b1e2d3c4-a1b2-c3d4-e5f6-a1b2c3d4e5f6" as UserId;
|
||||
const mockEmail = "test@example.com";
|
||||
const mockPin = "1234";
|
||||
const mockMasterPassword = "master-password";
|
||||
const mockKdfParams = { type: "pbkdf2" } as any;
|
||||
const mockAccountCryptographicState = { some: "state" } as any;
|
||||
const mockPinProtectedUserKeyEnvelope = { some: "envelope" } as any;
|
||||
const mockMasterPasswordUnlockData = { some: "unlockData", salt: "salt", kdf: "pbkdf2" } as any;
|
||||
|
||||
describe("DefaultUnlockService", () => {
|
||||
const registerSdkService = mock<RegisterSdkService>();
|
||||
const accountCryptographicStateService = mock<AccountCryptographicStateService>();
|
||||
const pinStateService = mock<PinStateServiceAbstraction>();
|
||||
const kdfService = mock<KdfConfigService>();
|
||||
const accountService = mock<AccountService>();
|
||||
const masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const stateProvider = mock<StateProvider>();
|
||||
const logService = mock<LogService>();
|
||||
const biometricsService = mock<BiometricsService>();
|
||||
|
||||
let service: DefaultUnlockService;
|
||||
let mockSdkRef: any;
|
||||
let mockSdk: any;
|
||||
let mockCrypto: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
|
||||
mockCrypto = {
|
||||
initialize_user_crypto: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockSdkRef = {
|
||||
value: {
|
||||
crypto: jest.fn().mockReturnValue(mockCrypto),
|
||||
},
|
||||
[Symbol.dispose]: jest.fn(),
|
||||
};
|
||||
|
||||
mockSdk = {
|
||||
take: jest.fn().mockReturnValue(mockSdkRef),
|
||||
};
|
||||
|
||||
registerSdkService.registerClient$.mockReturnValue(of(mockSdk));
|
||||
accountCryptographicStateService.accountCryptographicState$.mockReturnValue(
|
||||
of(mockAccountCryptographicState),
|
||||
);
|
||||
kdfService.getKdfConfig$.mockReturnValue(of({ toSdkConfig: () => mockKdfParams } as any));
|
||||
accountService.accounts$ = of({
|
||||
[mockUserId]: { email: mockEmail },
|
||||
} as any);
|
||||
pinStateService.getPinLockType.mockResolvedValue("PERSISTENT" as any);
|
||||
pinStateService.getPinProtectedUserKeyEnvelope.mockResolvedValue(
|
||||
mockPinProtectedUserKeyEnvelope,
|
||||
);
|
||||
masterPasswordService.masterPasswordUnlockData$.mockReturnValue(
|
||||
of({ toSdk: () => mockMasterPasswordUnlockData } as any),
|
||||
);
|
||||
|
||||
Object.defineProperty(SdkLoadService, "Ready", {
|
||||
value: Promise.resolve(),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
jest.spyOn(PureCrypto, "derive_kdf_material").mockReturnValue(new Uint8Array(32));
|
||||
|
||||
cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32));
|
||||
|
||||
const mockStateUpdate = jest.fn().mockResolvedValue(undefined);
|
||||
stateProvider.getUser.mockReturnValue({ update: mockStateUpdate } as any);
|
||||
|
||||
service = new DefaultUnlockService(
|
||||
registerSdkService,
|
||||
accountCryptographicStateService,
|
||||
pinStateService,
|
||||
kdfService,
|
||||
accountService,
|
||||
masterPasswordService,
|
||||
cryptoFunctionService,
|
||||
stateProvider,
|
||||
logService,
|
||||
biometricsService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("unlockWithPin", () => {
|
||||
it("calls SDK initialize_user_crypto with correct pin method", async () => {
|
||||
await service.unlockWithPin(mockUserId, mockPin);
|
||||
|
||||
expect(mockCrypto.initialize_user_crypto).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
kdfParams: mockKdfParams,
|
||||
email: mockEmail,
|
||||
accountCryptographicState: mockAccountCryptographicState,
|
||||
method: {
|
||||
pinEnvelope: {
|
||||
pin: mockPin,
|
||||
pin_protected_user_key_envelope: mockPinProtectedUserKeyEnvelope,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when SDK is not available", async () => {
|
||||
registerSdkService.registerClient$.mockReturnValue(of(null as any));
|
||||
|
||||
await expect(service.unlockWithPin(mockUserId, mockPin)).rejects.toThrow("SDK not available");
|
||||
});
|
||||
|
||||
it("fetches PERSISTENT pin envelope when the pin lock type is persistent", async () => {
|
||||
pinStateService.getPinLockType.mockResolvedValue("PERSISTENT" as any);
|
||||
await service.unlockWithPin(mockUserId, mockPin);
|
||||
expect(pinStateService.getPinProtectedUserKeyEnvelope).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
"PERSISTENT",
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches EPHEMERAL pin envelope when the pin lock type is ephemeral", async () => {
|
||||
pinStateService.getPinLockType.mockResolvedValue("EPHEMERAL" as any);
|
||||
await service.unlockWithPin(mockUserId, mockPin);
|
||||
expect(pinStateService.getPinProtectedUserKeyEnvelope).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
"EPHEMERAL",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlockWithMasterPassword", () => {
|
||||
it("calls SDK initialize_user_crypto with correct master password method", async () => {
|
||||
await service.unlockWithMasterPassword(mockUserId, mockMasterPassword);
|
||||
|
||||
expect(mockCrypto.initialize_user_crypto).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
kdfParams: mockKdfParams,
|
||||
email: mockEmail,
|
||||
accountCryptographicState: mockAccountCryptographicState,
|
||||
method: {
|
||||
masterPasswordUnlock: {
|
||||
password: mockMasterPassword,
|
||||
master_password_unlock: mockMasterPasswordUnlockData,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when SDK is not available", async () => {
|
||||
registerSdkService.registerClient$.mockReturnValue(of(null as any));
|
||||
|
||||
await expect(
|
||||
service.unlockWithMasterPassword(mockUserId, mockMasterPassword),
|
||||
).rejects.toThrow("SDK not available");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlockWithBiometrics", () => {
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as UserKey;
|
||||
|
||||
it("calls SDK initialize_user_crypto with decrypted key from biometrics", async () => {
|
||||
biometricsService.unlockWithBiometricsForUser.mockResolvedValue(mockUserKey);
|
||||
|
||||
await service.unlockWithBiometrics(mockUserId);
|
||||
|
||||
expect(biometricsService.unlockWithBiometricsForUser).toHaveBeenCalledWith(mockUserId);
|
||||
expect(mockCrypto.initialize_user_crypto).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
kdfParams: mockKdfParams,
|
||||
email: mockEmail,
|
||||
accountCryptographicState: mockAccountCryptographicState,
|
||||
method: {
|
||||
decryptedKey: {
|
||||
decrypted_user_key: mockUserKey.toBase64(),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when biometrics returns null", async () => {
|
||||
biometricsService.unlockWithBiometricsForUser.mockResolvedValue(null);
|
||||
|
||||
await expect(service.unlockWithBiometrics(mockUserId)).rejects.toThrow(
|
||||
"Failed to unlock with biometrics",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when SDK is not available", async () => {
|
||||
biometricsService.unlockWithBiometricsForUser.mockResolvedValue(mockUserKey);
|
||||
registerSdkService.registerClient$.mockReturnValue(of(null as any));
|
||||
|
||||
await expect(service.unlockWithBiometrics(mockUserId)).rejects.toThrow("SDK not available");
|
||||
});
|
||||
});
|
||||
});
|
||||
227
libs/unlock/src/default-unlock.service.ts
Normal file
227
libs/unlock/src/default-unlock.service.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { assertNonNullish } from "@bitwarden/common/auth/utils";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
MASTER_KEY,
|
||||
MASTER_KEY_HASH,
|
||||
} from "@bitwarden/common/key-management/master-password/services/master-password.service";
|
||||
import { PinStateServiceAbstraction } from "@bitwarden/common/key-management/pin/pin-state.service.abstraction";
|
||||
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
|
||||
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
|
||||
import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { MasterKey } from "@bitwarden/common/types/key";
|
||||
import { BiometricsService, KdfConfig, KdfConfigService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import {
|
||||
Kdf,
|
||||
MasterPasswordUnlockData,
|
||||
PasswordProtectedKeyEnvelope,
|
||||
PureCrypto,
|
||||
WrappedAccountCryptographicState,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
import { StateProvider } from "@bitwarden/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { UnlockService } from "./unlock.service";
|
||||
|
||||
export class DefaultUnlockService implements UnlockService {
|
||||
constructor(
|
||||
private registerSdkService: RegisterSdkService,
|
||||
private accountCryptographicStateService: AccountCryptographicStateService,
|
||||
private pinStateService: PinStateServiceAbstraction,
|
||||
private kdfService: KdfConfigService,
|
||||
private accountService: AccountService,
|
||||
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private stateProvider: StateProvider,
|
||||
private logService: LogService,
|
||||
private biometricsService: BiometricsService,
|
||||
) {}
|
||||
|
||||
async unlockWithPin(userId: UserId, pin: string): Promise<void> {
|
||||
const startTime = performance.now();
|
||||
await firstValueFrom(
|
||||
this.registerSdkService.registerClient$(userId).pipe(
|
||||
map(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
return ref.value.crypto().initialize_user_crypto({
|
||||
userId: asUuid(userId),
|
||||
kdfParams: await this.getKdfParams(userId),
|
||||
email: await this.getEmail(userId)!,
|
||||
accountCryptographicState: await this.getAccountCryptographicState(userId),
|
||||
method: {
|
||||
pinEnvelope: {
|
||||
pin: pin,
|
||||
pin_protected_user_key_envelope: await this.getPinProtectedUserKeyEnvelope(userId),
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.logService.measure(startTime, "Unlock", "DefaultUnlockService", "unlockWithPin");
|
||||
}
|
||||
|
||||
async unlockWithMasterPassword(userId: UserId, masterPassword: string): Promise<void> {
|
||||
const startTime = performance.now();
|
||||
await firstValueFrom(
|
||||
this.registerSdkService.registerClient$(userId).pipe(
|
||||
map(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
return ref.value.crypto().initialize_user_crypto({
|
||||
userId: asUuid(userId),
|
||||
kdfParams: await this.getKdfParams(userId),
|
||||
email: await this.getEmail(userId),
|
||||
accountCryptographicState: await this.getAccountCryptographicState(userId),
|
||||
method: {
|
||||
masterPasswordUnlock: {
|
||||
password: masterPassword,
|
||||
master_password_unlock: await this.getMasterPasswordUnlockData(userId),
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
await this.setLegacyMasterKeyFromUnlockData(
|
||||
masterPassword,
|
||||
await this.getMasterPasswordUnlockData(userId),
|
||||
userId,
|
||||
);
|
||||
this.logService.measure(
|
||||
startTime,
|
||||
"Unlock",
|
||||
"DefaultUnlockService",
|
||||
"unlockWithMasterPassword",
|
||||
);
|
||||
}
|
||||
|
||||
async unlockWithBiometrics(userId: UserId): Promise<void> {
|
||||
// First, get the biometrics-protected user key. This will prompt the user to authenticate with biometrics.
|
||||
const userKey = await this.biometricsService.unlockWithBiometricsForUser(userId);
|
||||
if (!userKey) {
|
||||
throw new Error("Failed to unlock with biometrics");
|
||||
}
|
||||
|
||||
// Now that we have the biometrics-protected user key, we can initialize the SDK with it to complete the unlock process.
|
||||
const startTime = performance.now();
|
||||
await firstValueFrom(
|
||||
this.registerSdkService.registerClient$(userId).pipe(
|
||||
map(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
using ref = sdk.take();
|
||||
return ref.value.crypto().initialize_user_crypto({
|
||||
userId: asUuid(userId),
|
||||
kdfParams: await this.getKdfParams(userId),
|
||||
email: await this.getEmail(userId),
|
||||
accountCryptographicState: await this.getAccountCryptographicState(userId),
|
||||
method: {
|
||||
decryptedKey: {
|
||||
decrypted_user_key: userKey.toBase64(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.logService.measure(startTime, "Unlock", "DefaultUnlockService", "unlockWithBiometrics");
|
||||
}
|
||||
|
||||
private async getAccountCryptographicState(
|
||||
userId: UserId,
|
||||
): Promise<WrappedAccountCryptographicState> {
|
||||
const accountCryptographicState = await firstValueFrom(
|
||||
this.accountCryptographicStateService.accountCryptographicState$(userId),
|
||||
);
|
||||
assertNonNullish(accountCryptographicState, "Account cryptographic state is required");
|
||||
return accountCryptographicState!;
|
||||
}
|
||||
|
||||
private async getKdfParams(userId: UserId): Promise<Kdf> {
|
||||
const kdfParams = await firstValueFrom(
|
||||
this.kdfService.getKdfConfig$(userId).pipe(
|
||||
map((config: KdfConfig | null) => {
|
||||
return config?.toSdkConfig();
|
||||
}),
|
||||
),
|
||||
);
|
||||
assertNonNullish(kdfParams, "KDF parameters are required");
|
||||
return kdfParams!;
|
||||
}
|
||||
|
||||
private async getEmail(userId: UserId): Promise<string> {
|
||||
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||
const email = accounts[userId].email;
|
||||
assertNonNullish(email, "Email is required");
|
||||
return email;
|
||||
}
|
||||
|
||||
private async getPinProtectedUserKeyEnvelope(
|
||||
userId: UserId,
|
||||
): Promise<PasswordProtectedKeyEnvelope> {
|
||||
const pinLockType = await this.pinStateService.getPinLockType(userId);
|
||||
const pinEnvelope = await this.pinStateService.getPinProtectedUserKeyEnvelope(
|
||||
userId,
|
||||
pinLockType,
|
||||
);
|
||||
assertNonNullish(pinEnvelope, "User is not enrolled in PIN unlock");
|
||||
return pinEnvelope!;
|
||||
}
|
||||
|
||||
private async getMasterPasswordUnlockData(userId: UserId): Promise<MasterPasswordUnlockData> {
|
||||
const unlockData = await firstValueFrom(
|
||||
this.masterPasswordService.masterPasswordUnlockData$(userId),
|
||||
);
|
||||
assertNonNullish(unlockData, "Master password unlock data is required");
|
||||
return unlockData.toSdk();
|
||||
}
|
||||
|
||||
private async setLegacyMasterKeyFromUnlockData(
|
||||
password: string,
|
||||
masterPasswordUnlockData: MasterPasswordUnlockData,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
assertNonNullish(password, "password");
|
||||
assertNonNullish(masterPasswordUnlockData, "masterPasswordUnlockData");
|
||||
assertNonNullish(userId, "userId");
|
||||
this.logService.info("[DefaultUnlockService] Setting legacy master key from unlock data");
|
||||
|
||||
// NOTE: This entire section is deprecated and will be removed as soon as
|
||||
// the masterkey is dropped from state. It is very temporary.
|
||||
await SdkLoadService.Ready;
|
||||
|
||||
const passwordBuffer = new TextEncoder().encode(password);
|
||||
const saltBuffer = new TextEncoder().encode(masterPasswordUnlockData.salt);
|
||||
const masterKey = PureCrypto.derive_kdf_material(
|
||||
passwordBuffer,
|
||||
saltBuffer,
|
||||
masterPasswordUnlockData.kdf,
|
||||
);
|
||||
const hash = await this.cryptoFunctionService.pbkdf2(
|
||||
masterKey,
|
||||
password,
|
||||
"sha256",
|
||||
2, // HashPurpose.LocalAuthorization
|
||||
);
|
||||
await this.stateProvider
|
||||
.getUser(userId, MASTER_KEY)
|
||||
.update((_) => new SymmetricCryptoKey(masterKey) as MasterKey);
|
||||
await this.stateProvider
|
||||
.getUser(userId, MASTER_KEY_HASH)
|
||||
.update((_) => Utils.fromBufferToB64(hash));
|
||||
}
|
||||
}
|
||||
2
libs/unlock/src/index.ts
Normal file
2
libs/unlock/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { UnlockService } from "./unlock.service";
|
||||
export { DefaultUnlockService } from "./default-unlock.service";
|
||||
35
libs/unlock/src/unlock.service.ts
Normal file
35
libs/unlock/src/unlock.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
/**
|
||||
* Service for unlocking a user's account with various methods.
|
||||
*/
|
||||
export abstract class UnlockService {
|
||||
/**
|
||||
* Unlocks the user's account using their PIN.
|
||||
*
|
||||
* @param userId - The user's id
|
||||
* @param pin - The user's PIN
|
||||
* @throws If the SDK is not available
|
||||
* @throws If the PIN is invalid or decryption fails
|
||||
*/
|
||||
abstract unlockWithPin(userId: UserId, pin: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Unlocks the user's account using their master password.
|
||||
*
|
||||
* @param userId - The user's id
|
||||
* @param masterPassword - The user's master password
|
||||
* @throws If the SDK is not available
|
||||
* @throws If the master password is invalid or decryption fails
|
||||
*/
|
||||
abstract unlockWithMasterPassword(userId: UserId, masterPassword: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Unlocks the user's account using biometrics.
|
||||
*
|
||||
* @param userId - The user's id
|
||||
* @throws If the SDK is not available
|
||||
* @throws If biometric authentication fails
|
||||
*/
|
||||
abstract unlockWithBiometrics(userId: UserId): Promise<void>;
|
||||
}
|
||||
Reference in New Issue
Block a user